Commit 00c379db by huahua

提交

parent 973e5556
/unpackage
/unpackage
*.dex
*.class
# Memory Bank
你熟悉 uni-app x框架,擅长编写跨平台且高性能的代码。
uni-app x项目使用UTS语言编写script。 UTS是一种跨平台的强类型语言,类似TS语言但类型要求更加严格。
## 语言使用规则
- **对话交流**:所有回复必须使用中文
- **文档编写**:README、说明文档、注释等均使用中文
- **代码注释**:使用中文编写所有注释(包括单行注释、多行注释、JSDoc)
- **代码命名**:变量名、函数名、类名、接口名等使用英文(遵循编程规范)
- **提交信息**:Git commit message 使用中文
- **错误提示**:用户可见的错误信息使用中文
## Code Style and Structure
- 简洁易懂,复杂的代码配上中文注释。
- 严格类型匹配,不使用隐式转换。
- 不使用变量和函数的声明提升,严格的在清晰的范围内使用变量和函数。
- 当生成某个平台专用代码时,应使用条件编译进行平台约束,避免干扰其他平台。
## project
- 遵循uni-app x的项目结构,在正确的目录中放置生成的文件。
## page
- 使用uvue作为页面后缀名,uvue与vue基本类似,但有少量细节差异。
- 生成的uvue页面放置在项目的pages目录下,生成的页面需要在pages.json中注册。
- 可滚动内容必须在scroll-view、list-view、waterflow等滚动容器中。如果页面需要滚动,则在页面template的一级子节点放置滚动容器,例如` <scroll-view style="flex:1">`。此时应在 App 上使用条件编译,例如: `<!-- #ifdef APP --><scroll-view class="container"><!-- #endif -->`
- 生成uvue页面时,页面内容需符合uts.mdc、uvue.mdc、ucss.mdc、api.mdc约定的规范。
# API
- 可以使用uts的api,但注意版本和平台的兼容性。
- 可以使用uni-app x的api,但注意版本和平台的兼容性。
- 可以使用vue3的api,但注意版本和平台的兼容性。
- 可以使用操作系统的api,但注意版本和平台的兼容性。尽量在uts插件中调用系统原生API,而不是在uvue页面中直接调用系统原生API。
- 特定平台或特定版本以上才能使用的代码,需使用条件编译包围这些代码,或者放置在平台专用的目录文件中。
- 通过mcp工具查询项目下可用的插件。
- 跨页面通信优先使用eventbus。
# uvue rules
## vue support
- 仅使用vue3语法,避免使用vue2。
- 新页面尽量使用组合式API。
- 组件尽量使用easycom规范。
- 非easycom的自定义vue组件,调用组件方法时需使用组件实例的`$callMethod`方式调用。
- 不使用 pinia、vuex、i18n 等uni-app x不支持的vue插件。
- 使用vue语法时需注意uni-app x官网的平台和版本兼容性,平台特殊代码需包裹在条件编译中。
## component
- 组件可使用uni-app x内置组件,以及项目下的自定义组件。通过mcp工具查询项目下可用的easycom插件。
- 项目可使用vuejs组件规范,对应的文件扩展名为uvue。
- 符合easycom规范的组件无需import和注册,可直接在template中使用。
- 使用内置组件时需注意uni-app x官网的平台和版本兼容性,平台特殊代码需包裹在条件编译中。
# conditional compilation
## core syntax
```
// Platform basic judgment
#ifdef APP || MP
//Mini programs/APP common code
#ifdef APP-ANDROID
// Android-specific logic
#endif
#ifdef APP-IOS
// IOS-specific logic
#endif
#endif
```
## Core Platform Identifier
uniVersion is used to distinguish the version of the compiler Details HBuilderX 3.9.0
APP App
APP-ANDROID App Android Platform Details
APP-IOS App iOS Platform Details
APP-HARMONY App HarmonyOS Next platform
WEB web (same as H5) HBuilderX 3.6.3
MP-WEIXIN WeChat Mini Program
MP-ALIPAY APPLET
MP-BAIDU BAIDU MINI PROGRAM
MP-TUTIAO TIKTOK MINI PROGRAM
MP-KUAISHOU Kuaishou Mini Program
MP-JD JD Mini Program
MP-HARMONY Harmony Atom Service HBuilderX 4.34
MP-XHS Xiaohongshu Mini Program
MP WeChat Mini Program/Alipay Mini Program/Baidu Mini Program/Douyin Mini Program/Feishu Mini Program/QQ Mini Program/360 Mini Program/Hongmeng atom Service
# UTS Rules
- 生成的脚本代码使用跨平台的UTS语言。
- UTS语言类似ts,但为了跨平台编译为kotlin、swift等强类型语言,进行了约束。
- UTS是强类型语言,类型要求严格,不能动态转换类型。与kotlin等强类型语言一样。
- 不能使用类型隐式转换。尤其是条件语句(if、while、do-while、三元运算符、for 循环的条件部分)必须使用布尔类型作为条件。当判断变量a是否为空时,不能写成 `if (a)`,或`if (!a)` 要写成 `if (a!=null)`
- 可为null和不可为null的类型需要严格区分,使用 `|null``?` 来定义可为空。
- 可为null的数据类型在使用其属性或方法时,需要判断不为null,或者使用`?.`安全调用。谨慎使用 `!.` 断言。
- any类型的变量在使用其属性或方法时,需要as为正确的相容类型。
- 不支持object类型,使用UTSJSONObject类型替代。
- 不支持undefined,变量使用前必须赋值。
- 对象类型定义使用type而不是interface。interface是接口,不用于对象类型定义。
- 变量和常量定义使用let和const,不使用var。
- 不使用 JSX 表达式。
- 不使用 with 语句。
- 不使用ts的结构化类型系统。使用名义类型系统,强调类型名称和继承关系以确保类型安全。
- 不使用 is 运算符。使用 instanceof 和 as 进行类型保护。
- 尽量不使用any。
- 尽量不使用 === 和!==,使用 == 和!= 替代。
- 不使用js的原型链特性。
- 严格遵守“先定义后使用”的规则。使用代码在定义代码之前。
- 更多参考: [uts与ts的差异](https://doc.dcloud.net.cn/uni-app-x/uts/uts_diff_ts.html)
# css rules
ucss是css的子集,但可以跨平台使用。除了浏览器之外,还支持App原生平台。
## 布局规范
- 禁用浮动、网格等布局,仅使用flex布局或绝对定位。
- flex布局默认方向为垂直(通过 flex-direction:column 实现)。
## 选择器规则
- 仅支持基本的类选择器 (.class),禁止使用其他选择器。
- 类名必须符合 [A-Za-z0-9_-]+ 规范,禁止使用特殊字符(例如 @class)。
## 文字样式规则
- 文字内容需放置在组件 <text> 或 <button> 中。 文字类样式(color、font-size)只能设置在 <text> 或 <button> 组件上。 其他组件(如<view>)禁止设置文本相关样式
- 文字样式不继承。
- 禁用继承相关关键字,例如 inherit 和 unset。
## 层级控制
- z-index 仅对同级兄弟节点生效。
- absolute 固定位与文档流分离,不支持分层覆盖。
## 长度单位
- 仅支持px、rpx、百分比。 字体的line-height支持em。 不能使用其他单位,如vh。
- 除非width需要根据屏幕宽度而变化才使用rpx单位。 其他场景不使用rpx单位。
- 除非长度单位需要根据父容器大小而变化才使用百分比单位。 其他场景不使用rpx单位。
## at-rules
- 仅支持`@font-face`、`@import`,不使用其他at-rules。
- 如需使用`@media` 适配不同屏幕,改用 uts 代码实现,先通过API `uni.getWindowInfo`获取屏幕宽度,再通过代码进行适配。
- 如需使用`@media` 适配暗黑模式,改用 uts 代码 和 css变量 实现。
- 如需使用`@keyframes`,改为通过UniElement对象的animate方法实现相同逻辑。
## css function
- 仅支持 url()、rgb()、rgba()、var()、env(),不使用其他css方法。
## 样式作用范围规则
- 不使用css scoped。
uni_modules
\ No newline at end of file
module.exports = {
// 指定换行的行长<int>,默认80
printWidth: 80,
// 指定每个缩进级别的空格数<int>,默认2
tabWidth: 2,
// 用制表符而不是空格缩进<bool>,默认false
useTabs: false,
// 在语句末尾添加分号<bool>,默认true
semi: true,
// 使用单引号而不是双引号<bool>,默认false
singleQuote: false,
// object对象中key值是否加引号<as-needed|consistent|preserve>,默认as-needed
// as-needed-仅在需要时在对象属性周围添加引号
// consistent-如果一个对象中至少有一个属性需要引号,所有属性添加引号
// preserve-保留对象属性中用户输入使用的引号
quoteProps: "as-needed",
// 在 JSX 中使用单引号而不是双引号<bool>,默认false
jsxSingleQuote: false,
// 在多行逗号分隔的句法结构中尽可能打印尾随逗号<es5|none|all>,默认es5
// es5-在 ES5 中有效的尾随逗号(对象、数组等),TypeScript 的类型参数中没有尾随逗号
// none-没有尾随逗号
// all-尽可能以逗号结尾(包括函数参数和调用)。要运行以这种方式格式化的 JavaScript 代码需要一个支持 ES2017(Node.js 8+ 或现代浏览器)或下层编译的引擎。这还会在 TypeScript 的类型参数中启用尾随逗号(自 2018 年 1 月发布的 TypeScript 2.7 起支持)
trailingComma: "es5",
// 对象字面量中括号之间的空格<bool>,默认true
bracketSpacing: true,
// 将>放在多行 HTML(HTML、JSX、Vue、Angular)元素最后一行的末尾,而不是单独放在下一行(不适用于自关闭元素)<bool>,默认false
// true:
// <button
// className="prettier-class"
// id="prettier-id"
// onClick={this.handleClick}>
// Click Here
// </button>
// false:
// <button
// className="prettier-class"
// id="prettier-id"
// onClick={this.handleClick}
// >
// Click Here
// </button>
bracketSameLine: true,
// 在唯一的箭头函数参数周围包含括号<always|avoid>,默认always
// always-始终包含括号
// avoid-尽可能省略括号
arrowParens: "always",
// Prettier 可以限制自己只格式化在文件顶部包含特殊注释(称为 pragma)的文件。这在逐渐将大型、未格式化的代码库过渡到 Prettier 时非常有用<bool>,默认false
requirePragma: false,
// Prettier可以在文件的顶部插入一个 @format 的特殊注释,以表明该文件已经被Prettier格式化过了。在使用 --require-pragma 参数处理一连串的文件时这个功能将十分有用。如果文件顶部已经有一个doclock,这个选项将新建一行注释,并打上 @format 标记<bool>,默认false
insertPragma: false,
// 超过最大宽度是否换行<always|never|preserve>,默认preserve
// always-如果超过最大宽度换行
// never-不要换行
// preserve-按原样显示
proseWrap: "preserve",
// 指定 HTML、Vue、Angular 和 Handlebars 的全局空格敏感度<css|strict|ignore>,默认css
// css-遵循CSS属性的默认值
// strict-所有标签周围的空格(或缺少空格)被认为是重要的
// ignore-所有标签周围的空格(或缺少空格)被认为是无关紧要的
htmlWhitespaceSensitivity: "ignore",
// vue文件script和style标签中是否缩进<bool>,默认false
vueIndentScriptAndStyle: false,
// 行尾换行符<lf|crlf|cr|auto>,默认lf
endOfLine: "lf",
// 控制 Prettier 是否格式化嵌入在文件中的引用代码<off|auto>,默认auto
// auto–如果 Prettier 可以自动识别,则格式化嵌入代码
// off-从不自动格式化嵌入代码
embeddedLanguageFormatting: "auto",
// 在 HTML、Vue 和 JSX 中强制执行每行单个属性<bool>,默认false
singleAttributePerLine: false,
};
<script lang="uts">
// #ifdef APP-ANDROID || APP-HARMONY
let firstBackTime = 0
// #endif
import { guestLogin } from './utils/reques.uts'
import type { ResponseData } from './utils/reques.uts'
// #ifdef APP
import { getDevicesInfo } from "@/uni_modules/zws-deviceInfo"
// #endif
async function getGuestLogin() {
const params = {} as object
try {
const res = await guestLogin(params) as ResponseData
if (res.code == 0) {
let guestToken : string = ''
if (res.data != null) {
const dataObj = res.data as UTSJSONObject
guestToken = dataObj.getString('guest_token') ?? ''
}
if (guestToken.length > 0) {
uni.setStorage({ key: 'guestToken', data: guestToken })
}
} else {
console.log('获取到的登录信息失败:', res.message)
}
} catch (error) {
console.log('获取到的登录信息失败:', error)
}
}
export default {
globalData: {
cameraMirror: false as boolean,
cameraPosition: 'back' as string // 'front' 或 'back'
},
onLaunch: function () {
// 初始化 device_id
// #ifdef APP
const storedDeviceId = uni.getStorageSync('device_id') as string | null
if (storedDeviceId == null || (storedDeviceId as string).length == 0) {
const deviceId = getDevicesInfo().ANDROID_ID as string
if (deviceId.length > 0) {
uni.setStorageSync('device_id', deviceId)
console.log('初始化 device_id:', deviceId)
}
}
// #endif
// #ifdef H5
const storedDeviceIdH5 = uni.getStorageSync('device_id') as string | null
if (storedDeviceIdH5 == null || (storedDeviceIdH5 as string).length == 0) {
uni.setStorageSync('device_id', 'e95615ac0e36794e')
console.log('初始化 H5 device_id: e95615ac0e36794e')
}
// #endif
getGuestLogin()
},
onShow: function () {
},
onHide: function () {
},
// #ifdef APP-ANDROID || APP-HARMONY
onLastPageBackPress: function () {
console.log('App LastPageBackPress')
if (firstBackTime == 0) {
uni.showToast({
title: '再按一次退出应用',
position: 'bottom',
})
firstBackTime = Date.now()
setTimeout(() => {
firstBackTime = 0
}, 2000)
} else if (Date.now() - firstBackTime < 2000) {
firstBackTime = Date.now()
uni.exit()
}
},
// #endif
onExit: function () {
console.log('App Exit')
}
}
</script>
<style>
.hover_btn {
opacity: 0.8;
}
.loading-text {
font-size: 24rpx;
color: #999999;
}
.empty-image {
width: 220rpx;
height: 220rpx;
margin-bottom: 20rpx;
}
</style>
\ No newline at end of file
<template>
<view v-if="visible" class="popup-overlay" @click="handleOverlayClick">
<view class="popup-overlay__content" @click="handleContentClick">
<view class="popup-container">
<image src="/static/popup.png" class="popup-bg-image" mode="aspectFit"></image>
<view class="popup-content">
<view class="popup-title">
<text class="title-text">发现新版本</text>
</view>
<view class="info-section">
<text class="info-text">当前版本:{{ currentVersion }}</text>
<text class="info-text">最新版本:{{ latestInfo!.versionName }}</text>
<text class="remark-text">更新说明:</text>
<text class="remark-text">{{ latestInfo!.remark }}</text>
</view>
<view v-if="downloading || installing" class="progress-section">
<view class="progress-container">
<view class="progress-bar">
<view class="progress-inner" :style="{ width: progressPercentStr }"></view>
<view class="progress-glow" :style="{ width: progressPercentStr }"></view>
</view>
<view class="progress-info">
<text class="progress-text" v-if="!installing">下载中 {{ progress }}%</text>
<text class="progress-text installing" v-else>正在安装...</text>
<text class="progress-percent">{{ progressPercentStr }}</text>
</view>
</view>
<!-- 下载完成动画效果 -->
<view v-if="downloadCompleted && !installing" class="download-success">
<image src="/static/icon_success.png" class="success-icon"></image>
<text class="success-text">下载完成</text>
</view>
</view>
<view class="btn-group">
<button class="primary-btn" hover-class="hover_btn" @click="handleUpdate">立即更新</button>
<!-- <button class="secondary-btn" hover-class="hover_btn" @click="handleClose">暂不更新</button> -->
</view>
</view>
</view>
<view class="close-button" @click="handleClose">
<image src="/static/icon_close.png" class="close-icon"></image>
</view>
</view>
</view>
</template>
<script setup lang="uts">
type UpgradeInfo = {
versionName: string
versionCode: number
downloadUrl: string
packageType: string
remark: string
}
// 使用 type 而不是 interface 定义对象类型,且可空类型明确
type AppUpgradeProps = {
visible: boolean
closeOnOverlay?: boolean | null
// 从父组件传入的版本信息对象,例如 { version: string, download_url: string }
versionInfo?: UTSJSONObject | null
}
type AppUpgradeEmits = {
(e: 'close'): void
(e: 'start'): void
(e: 'downloaded'): void
(e: 'installed'): void
}
const props = withDefaults(defineProps<AppUpgradeProps>(), {
visible: false,
closeOnOverlay: false
})
const emit = defineEmits<AppUpgradeEmits>()
const currentVersion = ref<string>('')
const latestInfo = ref<UpgradeInfo | null>(null)
const downloading = ref<boolean>(false)
const progress = ref<number>(0)
const downloadCompleted = ref<boolean>(false)
const installing = ref<boolean>(false)
const progressPercentStr = computed((): string => {
const p = progress.value
const clamped = p < 0 ? 0 : (p > 100 ? 100 : p)
return clamped.toString() + '%'
})
// 监听进度变化,当达到100%时自动安装
watch(progress, (newProgress: number): void => {
if (newProgress >= 100 && downloading.value && !downloadCompleted.value) {
downloadCompleted.value = true
// 延迟一点时间让用户看到100%的进度
setTimeout(() => {
if (latestInfo.value?.packageType == 'apk') {
installing.value = true
}
}, 500)
}
})
const readCurrentVersion = (): void => {
const base = uni.getAppBaseInfo()
const vName = base.appVersion ?? ''
currentVersion.value = vName
}
const inferPackageType = (url: string): string => {
const lower = url.toLowerCase()
if (lower.endsWith('.apk')) {
return 'apk'
}
if (lower.endsWith('.wgt')) {
return 'wgt'
}
return ''
}
const parseUpgradeInfo = (obj: UTSJSONObject): UpgradeInfo => {
// 父组件传入的结构:{ version: string, download_url: string }
const versionName = obj.getString('version') ?? ''
const downloadUrl = obj.getString('download_url') ?? ''
const pkgType = inferPackageType(downloadUrl)
return {
versionName: versionName,
versionCode: 0,
downloadUrl: downloadUrl,
packageType: pkgType,
remark: obj.getString('remark') ?? ''
}
}
// 监听外部传入的版本信息并解析
const versionInfoRef = computed<UTSJSONObject | null>(() => props.versionInfo ?? null)
watch(versionInfoRef, (nv: UTSJSONObject | null, _ov: UTSJSONObject | null): void => {
if (nv != null) {
latestInfo.value = parseUpgradeInfo(nv)
} else {
latestInfo.value = null
}
}, { immediate: true })
const handleClose = (): void => {
downloading.value = false
downloadCompleted.value = false
installing.value = false
progress.value = 0
emit('close')
}
const handleOverlayClick = (): void => {
if (props.closeOnOverlay == true) {
handleClose()
}
}
const handleContentClick = (): void => {
// 阻止关闭
}
const handleUpdate = (): void => {
const info = latestInfo.value
if (info == null) {
uni.showToast({ title: '未获取到版本信息', icon: 'none' })
return
}
if (info.downloadUrl.length == 0) {
uni.showToast({ title: '无效的下载地址', icon: 'none' })
return
}
emit('start')
downloading.value = true
progress.value = 0
// 仅在 App 平台进行下载与安装
// #ifdef APP
const task = uni.downloadFile({
url: info.downloadUrl,
success: (res) => {
emit('downloaded')
// UTS 强类型:使用UTSJSONObject方式访问属性
console.log('res',res)
const fp = res['tempFilePath'] as string ?? ''
if (fp.length == 0) {
uni.showToast({ title: '下载文件路径异常', icon: 'none' })
downloading.value = false
return
}
if (info.packageType == 'apk') {
// 仅 Android 安装 APK
// #ifdef APP-ANDROID
uni.installApk({
filePath: fp,
success: (_res: any) => {
emit('installed')
downloading.value = false
},
fail: (_err: any) => {
uni.showToast({ title: '安装失败', icon: 'none' })
downloading.value = false
},
complete: (_: any) => {}
})
// #endif
// #ifdef APP-IOS
uni.showToast({ title: 'iOS 不支持 APK 安装', icon: 'none' })
downloading.value = false
// #endif
} else if (info.packageType == 'wgt') {
// wgt 热更新在 uni-app x 中更推荐使用官方“App 升级中心”
uni.showToast({ title: '请接入 App 升级中心以安装 wgt', icon: 'none' })
downloading.value = false
} else {
uni.showToast({ title: '未知的包类型', icon: 'none' })
downloading.value = false
}
},
fail: (_err: any) => {
uni.showToast({ title: '下载失败', icon: 'none' })
downloading.value = false
}
})
// 监听下载进度
const t = task as DownloadTask
t.onProgressUpdate((result) => {
// console.log('result',result)
// 根据 uni-app x 官方文档,OnProgressDownloadResult 包含 progress 属性
const p = result.progress
progress.value = p
})
// #endif
// #ifndef APP
uni.showToast({ title: '仅 App 平台支持安装', icon: 'none' })
downloading.value = false
// #endif
}
watch((): boolean => props.visible, (nv: boolean): void => {
if (nv == true) {
readCurrentVersion()
} else {
downloading.value = false
downloadCompleted.value = false
installing.value = false
progress.value = 0
}
})
onMounted(() => {
readCurrentVersion()
})
</script>
<style>
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
}
.popup-overlay__content {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.popup-container {
width: 498.61rpx;
min-height: 547.92rpx;
position: relative;
}
.popup-bg-image {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
.popup-content {
position: relative;
z-index: 2;
width: 100%;
height: 100%;
}
.popup-title {
margin: 80rpx 0 30rpx 55.56rpx;
}
.title-text {
font-size: 36rpx;
font-weight: bold;
color: #21061A;
line-height: 1.2em;
}
.info-section {
margin: 0 0 20rpx 55.56rpx;
}
.info-text {
font-size: 24rpx;
color: #000000;
line-height: 1.4em;
font-weight: 400;
}
.remark-text {
margin-top: 20rpx;
font-size: 28rpx;
color: #ffffffff;
line-height: 1.4em;
font-weight: 400;
}
.progress-section {
margin: 20rpx 55.56rpx;
width: 380rpx;
}
.progress-container {
width: 100%;
}
.progress-bar {
width: 100%;
height: 20rpx;
background: linear-gradient(90deg, #f0f0f0 0%, #e8e8e8 100%);
border-radius: 10rpx;
position: relative;
overflow: hidden;
box-shadow: inset 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.progress-inner {
height: 20rpx;
background: linear-gradient(90deg, #FD3C9F 0%, #FF6B9D 50%, #FCA7D3 100%);
border-radius: 10rpx;
width: 0%;
transition: width 0.3s ease-in-out;
position: relative;
z-index: 2;
}
.progress-glow {
position: absolute;
top: 0;
left: 0;
height: 20rpx;
background: linear-gradient(90deg, rgba(253, 60, 159, 0.3) 0%, rgba(255, 107, 157, 0.3) 50%, rgba(252, 167, 211, 0.3) 100%);
border-radius: 10rpx;
width: 0%;
z-index: 1;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16rpx;
}
.progress-text {
font-size: 24rpx;
color: #333333;
font-weight: bold;
}
.progress-text.installing {
color: #FD3C9F;
font-weight: bold;
}
.progress-percent {
font-size: 24rpx;
color: #FD3C9F;
font-weight: bold;
}
.download-success {
display: flex;
align-items: center;
justify-content: center;
margin-top: 20rpx;
padding: 16rpx;
background: rgba(253, 60, 159, 0.1);
border-radius: 12rpx;
border: 2rpx solid rgba(253, 60, 159, 0.2);
}
.success-icon {
width: 32rpx;
height: 32rpx;
margin-right: 12rpx;
}
.success-text {
font-size: 26rpx;
color: #FD3C9F;
font-weight: bold;
}
.btn-group {
display: flex;
justify-content: flex-start;
margin: 20rpx 0 0 55rpx;
flex-direction: row;
}
.primary-btn {
width: 194rpx;
height: 67rpx;
background: linear-gradient(to bottom, #FD3C9F, #FCA7D3);
font-weight: bold;
font-size: 25rpx;
color: #000000;
line-height: 67rpx;
border-radius: 20rpx;
border: none;
margin-right: 16rpx;
}
.secondary-btn {
width: 194rpx;
height: 67rpx;
background: #fff5fa;
font-weight: bold;
font-size: 25rpx;
color: #000000;
line-height: 67rpx;
border-radius: 20rpx;
border: none;
}
.close-button {
margin-top: 21rpx;
width: 48rpx;
height: 48rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
border-radius: 25rpx;
display: flex;
justify-content: center;
align-items: center;
}
.close-icon {
width: 48rpx;
height: 48rpx;
}
</style>
\ No newline at end of file
<template>
<view v-if="visible" class="category-selector-mask" @click="handleMaskClick">
<view class="category-selector-container" :style="containerStyle" @click.stop>
<!-- 顶部标题栏 -->
<view class="header">
<view class="back-btn" @click="handleClose">
<text class="back-icon"></text>
</view>
<text class="title">分类</text>
<view class="search-btn">
<text class="search-icon"></text>
</view>
</view>
<!-- 主内容区域 -->
<view class="content">
<!-- 左侧分类列表 -->
<scroll-view class="category-list" scroll-y="true" show-scrollbar="false">
<view
v-for="(category, index) in categories"
:key="category.value"
:class="['category-item', activeCategoryIndex === index ? 'category-active' : '']"
@click="handleCategoryClick(index)"
>
<image
v-if="categoryList[index].default_icon.length > 0"
class="category-icon"
:src="categoryList[index].default_icon"
mode="aspectFit"
></image>
<text :class="['category-text', activeCategoryIndex === index ? 'category-text-active' : '']">
{{ category.label }}
</text>
<view v-if="activeCategoryIndex === index" class="category-indicator"></view>
</view>
</scroll-view>
<!-- 右侧商品展示区域 -->
<view class="product-area-wrapper">
<!-- 标签栏 -->
<scroll-view class="tags-container-right" scroll-x="true" show-scrollbar="false">
<view class="tags-wrapper-right">
<view
v-for="(tag, index) in tags"
:key="index"
:class="['tag-item-right', activeTagIndex === index ? 'tag-active-right' : '']"
@click="handleTagClick(index)"
>
<text :class="['tag-text-right', activeTagIndex === index ? 'tag-text-active-right' : '']">{{ tag }}</text>
</view>
</view>
</scroll-view>
<scroll-view class="product-area" scroll-y="true" show-scrollbar="false">
<!-- 加载状态 -->
<view v-if="loading" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
<!-- 商品列表 -->
<view v-else-if="currentProducts.length > 0" class="product-list">
<view
v-for="(product, index) in currentProducts"
:key="product.value"
class="product-item"
@click="handleProductClick(product)"
>
<image class="product-image" :src="product.goods_pic" mode="aspectFit"></image>
<view class="product-info">
<text class="product-name">{{ product.label }}</text>
<text class="product-price">¥{{ product.price }}</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else class="empty-container">
<text class="empty-text">暂无商品</text>
</view>
</scroll-view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed, watch } from 'vue'
import type { machineItems, categoryItem } from '@/types/globalData.uts'
import { getgoods, getmachines, goodscategory } from '@/utils/reques.uts'
import type { ResponseData } from '@/utils/reques.uts'
// 定义商品类型
type Product = {
label: string
value: number
front_image: string
goods_pic: string
render_width: string
render_height: string
category_value: string
design_color: string
price: string
machine_id: number
}
// 定义分类类型
type Category = {
value: string
label: string
products: Product[]
}
// Props定义
const props = defineProps<{
visible: boolean
}>()
// Emits定义
const emit = defineEmits<{
close: []
select: [product: UTSJSONObject]
}>()
// 响应式数据
const activeTagIndex = ref(0)
const activeCategoryIndex = ref(0)
const translateY = ref(100)
const loading = ref(false)
// 存储分类商品数据的Map,key为 "machineId_categoryValue"
const categoryProductsMap = ref(new Map<string, Product[]>())
// 存储设备列表数据的Map,key为 "categoryValue"
const machinesListMap = ref(new Map<string, Array<machineItems>>())
// 设备列表数据
const machinesList = ref<Array<machineItems>>([])
// 分类列表数据
const categoryList = ref<Array<categoryItem>>([])
// 获取当前选中的设备ID
const currentMachineId = computed((): number => {
if (machinesList.value.length > 0 && activeTagIndex.value < machinesList.value.length) {
const machineValue = machinesList.value[activeTagIndex.value].value
// 将 value 转换为 number 类型
if (typeof machineValue == 'number') {
return machineValue as number
}
}
return 0
})
// 获取当前选中的分类值
const currentCategoryValue = computed((): string => {
if (categoryList.value.length > 0 && activeCategoryIndex.value < categoryList.value.length) {
return categoryList.value[activeCategoryIndex.value].value
}
return ''
})
// 标签数据 - 从 machinesList 获取
const tags = computed((): string[] => {
return machinesList.value.map((item: machineItems): string => {
return item.label
})
})
// 分类数据 - 从 categoryList 获取
const categories = computed((): Category[] => {
return categoryList.value.map((item: categoryItem): Category => {
const cacheKey = `${currentMachineId.value}_${item.value}`
const cachedProducts = categoryProductsMap.value.get(cacheKey)
return {
value: item.value,
label: item.label,
products: cachedProducts != null ? cachedProducts : [] as Product[]
}
})
})
// 计算当前分类标题
const currentCategoryTitle = computed((): string => {
const cats = categories.value
if (cats.length > 0 && activeCategoryIndex.value < cats.length) {
return cats[activeCategoryIndex.value].label
}
return ''
})
// 计算当前商品列表
const currentProducts = computed((): Product[] => {
const cats = categories.value
if (cats.length > 0 && activeCategoryIndex.value < cats.length) {
return cats[activeCategoryIndex.value].products
}
return [] as Product[]
})
// 计算容器样式
const containerStyle = computed((): string => {
return `transform: translateY(${translateY.value}%);`
})
// 获取设备列表 - 需要传递 category_value 参数
const fetchMachinesList = async (categoryValue: string) => {
// 如果已有缓存数据,直接使用
if (machinesListMap.value.has(categoryValue)) {
const cachedMachines = machinesListMap.value.get(categoryValue)
if (cachedMachines != null) {
machinesList.value = cachedMachines
// 重置为第一个设备
activeTagIndex.value = 0
return
}
}
try {
const params = {
category_value: categoryValue
} as UTSJSONObject
const res = await getmachines(params) as ResponseData
if (res.code as number == 0) {
const dataArray = res.data as Array<any>
const machines : Array<machineItems> = []
for (let i = 0; i < dataArray.length; i++) {
const item = dataArray[i] as UTSJSONObject
const machineitems : machineItems = {
label: item.getString('name') ?? '',
value: item.getNumber('id') ?? 0,
}
machines.push(machineitems)
}
// 缓存设备列表数据
machinesListMap.value.set(categoryValue, machines)
machinesList.value = machines
// 重置为第一个设备
activeTagIndex.value = 0
}
} catch (e) {
console.log('获取设备列表失败:', e)
}
}
// 获取产品分类列表
const fetchCategoryList = async () => {
try {
const params = {} as object
const res = await goodscategory(params) as ResponseData
if (res.code as number == 0) {
const dataArray = res.data as Array<any>
const categorys : Array<categoryItem> = []
for (let i = 0; i < dataArray.length; i++) {
const item = dataArray[i] as UTSJSONObject
const categoryItem : categoryItem = {
label: item.getString('label') ?? '',
value: item.getString('value') ?? '',
default_icon: item.getString('default_icon') ?? ''
}
categorys.push(categoryItem)
}
categoryList.value = categorys
}
} catch (e) {
console.log('获取产品分类列表失败:', e)
}
}
// 获取商品数据
const fetchGoods = async (machineId: number, categoryValue: string) => {
const cacheKey = `${machineId}_${categoryValue}`
// 如果已有缓存数据,直接返回
if (categoryProductsMap.value.has(cacheKey)) {
return
}
loading.value = true
try {
const params = {
machine_id: machineId,
category_value: categoryValue
} as UTSJSONObject
const res = await getgoods(params) as ResponseData
if (res.code == 0 && res.data != null) {
const dataArray = res.data as Array<UTSJSONObject>
const products = dataArray.map((item: UTSJSONObject): Product => {
return {
label: item.getString('name') ?? '',
value: item.getNumber('id') ?? 0,
front_image: item.getString('front_image') ?? '',
goods_pic: item.getString('goods_pic') ?? '',
render_width: item.getString('render_width') ?? '',
render_height: item.getString('render_height') ?? '',
category_value: categoryValue,
design_color: item.getString('design_color') ?? '',
price: item.getString('price') ?? '0',
machine_id: machineId
}
})
// 缓存数据
categoryProductsMap.value.set(cacheKey, products)
}
} catch (error) {
console.log('获取商品数据失败:', error)
} finally {
loading.value = false
}
}
// 监听visible变化,执行动画并加载数据
watch((): boolean => props.visible, async (newVal: boolean) => {
if (newVal) {
// 重置为默认选中第一个分类
activeCategoryIndex.value = 0
// 打开动画
translateY.value = 100
setTimeout(() => {
translateY.value = 0
}, 50)
// 先加载分类列表
if (categoryList.value.length == 0) {
await fetchCategoryList()
}
// 获取当前分类值
const categoryValue = currentCategoryValue.value
if (categoryValue.length > 0) {
// 根据分类获取设备列表
await fetchMachinesList(categoryValue)
// 加载当前分类的商品数据
const machineId = currentMachineId.value
if (machineId > 0) {
fetchGoods(machineId, categoryValue)
}
}
} else {
// 关闭动画
translateY.value = 100
}
})
// 监听标签切换
watch(activeTagIndex, () => {
const machineId = currentMachineId.value
const categoryValue = currentCategoryValue.value
if (machineId > 0 && categoryValue.length > 0) {
fetchGoods(machineId, categoryValue)
}
})
// 监听分类切换 - 需要重新获取设备列表
watch(activeCategoryIndex, async () => {
const categoryValue = currentCategoryValue.value
if (categoryValue.length > 0) {
// 根据新分类获取设备列表
await fetchMachinesList(categoryValue)
// 加载当前分类的商品数据
const machineId = currentMachineId.value
if (machineId > 0) {
fetchGoods(machineId, categoryValue)
}
}
})
// 处理关闭
const handleClose = () => {
translateY.value = 100
setTimeout(() => {
emit('close')
}, 300)
}
// 处理标签点击
const handleTagClick = (index: number) => {
activeTagIndex.value = index
}
// 处理分类点击
const handleCategoryClick = (index: number) => {
activeCategoryIndex.value = index
}
// 处理商品点击
const handleProductClick = (product: Product) => {
// 将 Product 转换为 UTSJSONObject
const productObj = {
label: product.label,
value: product.value,
front_image: product.front_image,
goods_pic: product.goods_pic,
render_width: product.render_width,
render_height: product.render_height,
category_value: product.category_value,
design_color: product.design_color,
price: product.price,
machine_id: product.machine_id
} as UTSJSONObject
emit('select', productObj)
}
// 处理遮罩点击
const handleMaskClick = () => {
handleClose()
}
</script>
<style>
.category-selector-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.category-selector-container {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 60%;
background-color: #f5f5f5;
border-top-left-radius: 40rpx;
border-top-right-radius: 40rpx;
flex-direction: column;
transition-property: transform;
transition-duration: 300ms;
transition-timing-function: ease-out;
}
.header {
height: 88rpx;
background-color: #ffffff;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding-left: 30rpx;
padding-right: 30rpx;
border-bottom-width: 2rpx;
border-bottom-color: #eeeeee;
}
.back-btn {
width: 80rpx;
height: 88rpx;
justify-content: center;
align-items: flex-start;
}
.back-icon {
font-size: 48rpx;
color: #333333;
}
.title {
font-size: 34rpx;
font-weight: bold;
color: #333333;
}
.search-btn {
width: 80rpx;
height: 88rpx;
justify-content: center;
align-items: flex-end;
}
.search-icon {
font-size: 40rpx;
color: #333333;
}
.product-area-wrapper {
flex: 1;
flex-direction: column;
background-color: #ffffff;
}
.tags-container-right {
height: 100rpx;
background-color: #ffffff;
flex-direction: row;
border-bottom-width: 2rpx;
border-bottom-color: #eeeeee;
}
.tags-wrapper-right {
flex-direction: row;
align-items: center;
padding-left: 30rpx;
padding-right: 30rpx;
}
.tag-item-right {
padding-left: 32rpx;
padding-right: 32rpx;
height: 64rpx;
justify-content: center;
align-items: center;
border-radius: 32rpx;
margin-right: 20rpx;
background-color: #ffffff;
}
.tag-active-right {
background-color: #ffe5e5;
border-width: 2rpx;
border-color: #ff4444;
}
.tag-text-right {
font-size: 28rpx;
color: #666666;
}
.tag-text-active-right {
color: #ff4444;
font-weight: bold;
}
.content {
flex: 1;
flex-direction: row;
}
.category-list {
width: 240rpx;
background-color: #f5f5f5;
flex-direction: column;
}
.category-item {
height: 160rpx;
justify-content: center;
align-items: center;
position: relative;
flex-direction: column;
padding-top: 16rpx;
padding-bottom: 16rpx;
}
.category-icon {
width: 64rpx;
height: 64rpx;
margin-bottom: 8rpx;
}
.category-active {
background-color: #ffffff;
}
.category-text {
font-size: 28rpx;
color: #666666;
}
.category-text-active {
color: #333333;
font-weight: bold;
}
.category-indicator {
position: absolute;
left: 0;
width: 6rpx;
height: 40rpx;
background-color: #ff4444;
}
.product-area {
flex: 1;
flex-direction: column;
padding-left: 30rpx;
padding-right: 30rpx;
padding-top: 30rpx;
}
.product-list {
flex-direction: column;
}
.product-item {
width: 100%;
padding: 24rpx;
margin-bottom: 20rpx;
flex-direction: row;
align-items: center;
background-color: #ffefef;
border-radius: 16rpx;
}
.product-image {
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
}
.product-info {
flex: 1;
margin-left: 24rpx;
flex-direction: column;
justify-content: center;
}
.product-name {
font-size: 30rpx;
color: #333333;
margin-bottom: 16rpx;
}
.product-price {
font-size: 32rpx;
color: #ff4444;
font-weight: bold;
}
.loading-container {
flex: 1;
justify-content: center;
align-items: center;
padding-top: 200rpx;
}
.loading-text {
font-size: 28rpx;
color: #999999;
}
.empty-container {
flex: 1;
justify-content: center;
align-items: center;
padding-top: 200rpx;
}
.empty-text {
font-size: 28rpx;
color: #999999;
}
</style>
<template>
<!-- 图形编辑器根容器 -->
<view class="editor-root">
<!-- 编辑容器包装 - 用于定位控制点,增加额外空间避免裁剪 -->
<view class="editor-wrapper" :style="wrapperStyle" @touchstart.stop="onWrapperTouchStart"
@touchmove.stop="onWrapperTouchMove" @touchend.stop="onWrapperTouchEnd" @mousedown.stop="onWrapperMouseDown"
@mousemove.stop="onWrapperMouseMove" @mouseup.stop="onWrapperMouseUp">
<!-- 编辑容器 -->
<view class="editor-container" :style="containerStyle" @click.stop="cancelSelection">
<!-- 图片层 -->
<view class="item-layer" :style="itemLayerStyle">
<image class="editor-image" :src="avatar" @load="onImageLoad" @click.stop="onImageClick"
:style="imageStyle" />
</view>
<!-- 隐藏的canvas用于生成裁剪结果 -->
<canvas id="croppingCanvas" canvas-id="croppingCanvas" class="cropping-canvas"
:style="canvasStyle"></canvas>
</view>
<!-- 背景遮罩图 - 放在编辑容器之后,这样它会覆盖图片但不会覆盖控制点 -->
<image v-if="fixturebg.length > 0" class="editor-mask-image" :src="fixturebg" :style="maskImageStyle">
</image>
<!-- 辅助线 - 垂直中心线 -->
<view v-if="showCenterGuides" class="guide-line guide-line-v"
:style="{ left: '50%', transform: 'translateX(-50%)' }"></view>
<!-- 辅助线 - 水平中心线 -->
<view v-if="showCenterGuides" class="guide-line guide-line-h"
:style="{ top: '35%', transform: 'translateY(-50%)' }"></view>
<!-- 动态辅助线 - 垂直 -->
<view v-for="(line, idx) in verticalGuides" :key="'v-' + idx"
class="guide-line guide-line-v guide-line-active" :style="{ left: (line + 50) + 'px' }"></view>
<!-- 动态辅助线 - 水平 -->
<view v-for="(line, idx) in horizontalGuides" :key="'h-' + idx"
class="guide-line guide-line-h guide-line-active" :style="{ top: (line + 50) + 'px' }"></view>
<!-- 四角控制点 - 放在 wrapper 中,相对于容器定位但不被裁剪 -->
<view v-if="showCorners" class="frame-left-top-wrap" :style="cornerWrapStyle('left-top')"
@touchstart.stop.prevent="(e: UniTouchEvent) => onCornerStart(e, 'left-top')"
@touchend.stop.prevent="onCornerEnd" @mousedown.stop.prevent="onCornerMouseStart($event, 'left-top')">
<view class="corner-inner"
:style="{'border-left-color': currentCorner == 'left-top' ? 'red' : '#fd3da0', 'border-top-color': currentCorner == 'left-top' ? '#F56364' : '#fd3da0'}">
</view>
</view>
<view v-if="showCorners" class="frame-right-top-wrap" :style="cornerWrapStyle('right-top')"
@touchstart.stop.prevent="(e: UniTouchEvent) => onCornerStart(e, 'right-top')"
@touchend.stop.prevent="onCornerEnd" @mousedown.stop.prevent="onCornerMouseStart($event, 'right-top')">
<view class="corner-inner"
:style="{'border-right-color': currentCorner == 'right-top' ? 'red' : '#fd3da0', 'border-top-color': currentCorner == 'right-top' ? '#F56364' : '#fd3da0'}">
</view>
</view>
<view v-if="showCorners" class="frame-left-bottom-wrap" :style="cornerWrapStyle('left-bottom')"
@touchstart.stop.prevent="(e: UniTouchEvent) => onCornerStart(e, 'left-bottom')"
@touchend.stop.prevent="onCornerEnd"
@mousedown.stop.prevent="onCornerMouseStart($event, 'left-bottom')">
<view class="corner-inner"
:style="{'border-left-color': currentCorner == 'left-bottom' ? 'red' : '#fd3da0', 'border-bottom-color': currentCorner == 'left-bottom' ? '#F56364' : '#fd3da0'}">
</view>
</view>
<view v-if="showCorners" class="frame-right-bottom-wrap" :style="cornerWrapStyle('right-bottom')"
@touchstart.stop.prevent="(e: UniTouchEvent) => onCornerStart(e, 'right-bottom')"
@touchend.stop.prevent="onCornerEnd"
@mousedown.stop.prevent="onCornerMouseStart($event, 'right-bottom')">
<view class="corner-inner"
:style="{'border-right-color': currentCorner == 'right-bottom' ? 'red' : '#fd3da0', 'border-bottom-color': currentCorner == 'right-bottom' ? '#F56364' : '#fd3da0'}">
</view>
</view>
<!-- 旋转控制点 -->
<!-- <view v-if="showCorners" :class="[operationType == 'rotate' ? 'rotate-red' : 'rotate']"
:style="rotateWrapStyle"
@touchstart.stop.prevent="onRotateStart"
@touchend.stop.prevent="onRotateEnd"
@mousedown.stop.prevent="onRotateMouseStart">
<image class="rotate-icon" src="/static/icon_rotate.png" mode="aspectFit"></image>
</view> -->
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { recordPressDownData, dragMove, scaleMove, edgeMove, rotateMove, updateCenterPos, applyAngleSnap, getDistance } from './dragTransform.uts'
import type { ItemElement, GuideLine } from './dragTransform.uts'
// 定义 emit
const emit = defineEmits<{
cropData : [cropInfo: UTSJSONObject],
editingChange : [isEditing: boolean]
}>()
// Props定义
const props = defineProps({
avatar: {
type: String,
required: true
},
sizewidth: {
type: Number,
default: 0
},
sizeheight: {
type: Number,
default: 0
},
isCropping: {
type: Boolean,
default: false
},
categoryValue: {
type: String,
default: ''
},
fixturebg: {
type: String,
default: ''
},
// 新增:是否启用磁性吸附
enableMagnetic: {
type: Boolean,
default: true
},
// 新增:是否显示辅助线
enableGuides: {
type: Boolean,
default: true
}
})
// 响应式数据 - 图片元素
const itemElement = ref<ItemElement>({
id: 1,
type: 0, // 图片类型
index: 1,
left: 0,
top: 0,
width: 0,
height: 0,
scale: 1,
angle: 0,
active: false,
activeguide: false,
activescale: false,
activehorn: false,
activeedge: false
})
// 编辑状态
const isSelected = ref<boolean>(false)
const operationType = ref<string>('') // 'drag', 'scale', 'edge', 'rotate'
const currentCorner = ref<string>('') // 当前操作的角
const showCorners = ref<boolean>(true)
const showEdges = ref<boolean>(false)
const showRotate = ref<boolean>(false)
const showCenterGuides = ref<boolean>(false)
// 辅助线
const verticalGuides = ref<number[]>([])
const horizontalGuides = ref<number[]>([])
const allGuideLines = ref<GuideLine[]>([])
// 基准尺寸
const baseWidth = ref<number>(0)
const baseHeight = ref<number>(0)
// 触摸/鼠标状态
const isTouching = ref<boolean>(false)
const isMouseDown = ref<boolean>(false)
const touchCount = ref<number>(0)
// 定义触摸位置类型
type TouchPosition = {
x : number
y : number
}
// 触摸状态(已改为触摸触发,不再使用长按)
const touchStartPos = ref<TouchPosition | null>(null)
// 双指手势状态
const isMultiTouch = ref<boolean>(false)
const initialDistance = ref<number>(0)
const initialAngle = ref<number>(0)
const initialScale = ref<number>(1)
const initialRotation = ref<number>(0)
const lastTouchTime = ref<number>(0)
const touchThrottle = 16 // 约60fps,减少更新频率
// 设备信息
const screenWidth = ref<number>(375)
const screenHeight = ref<number>(667)
// 缩放限制
const minScale = ref<number>(0.5) // 最小缩放50%
const maxScale = ref<number>(4) // 最大缩放,将根据屏幕尺寸动态计算
// ==================== 打印图片左上角位置 ====================
const printImagePosition = (actionName : string) => {
const item = itemElement.value
// 计算缩放比例(显示尺寸相对于原始尺寸的比例)
const newsizewidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const scaleRatio = props.sizewidth / newsizewidth
// 根据当前缩放值选择合适的偏移系数
const SCALE_DOWN_ADJUSTMENT = 0.92
const SCALE_UP_ADJUSTMENT = 1.206
const OFFSET_ADJUSTMENT = item.scale < 1 ? SCALE_DOWN_ADJUSTMENT : SCALE_UP_ADJUSTMENT
// 计算图片在容器中的实际显示尺寸(考虑 scale)
const actualDisplayWidth = item.width * item.scale
const actualDisplayHeight = item.height * item.scale
// 计算图片左上角的实际位置(考虑 scale 导致的中心偏移)
const scaleOffset = (1 - item.scale) / 2 * OFFSET_ADJUSTMENT
const actualLeft = item.left + item.width * scaleOffset
const actualTop = item.top + item.height * scaleOffset
// 将显示坐标和尺寸转换回原始尺寸
const originalX = actualLeft * scaleRatio
const originalY = actualTop * scaleRatio
const originalWidth = actualDisplayWidth * scaleRatio
const originalHeight = actualDisplayHeight * scaleRatio
console.log(`========== ${actionName} ==========`)
console.log('图片左上角位置(显示坐标):', {
x: actualLeft.toFixed(2),
y: actualTop.toFixed(2)
})
console.log('图片左上角位置(原始坐标):', {
x: originalX.toFixed(2),
y: originalY.toFixed(2)
})
console.log('图片尺寸(显示):', {
width: actualDisplayWidth.toFixed(2),
height: actualDisplayHeight.toFixed(2)
})
console.log('图片尺寸(原始):', {
width: originalWidth.toFixed(2),
height: originalHeight.toFixed(2)
})
console.log('图片变换:', {
scale: item.scale.toFixed(3),
rotation: item.angle.toFixed(2) + '°'
})
console.log('==============================')
}
// 初始化设备信息
const initDeviceInfo = () => {
try {
const systemInfo = uni.getSystemInfoSync()
screenWidth.value = systemInfo.screenWidth as number
screenHeight.value = systemInfo.screenHeight as number
console.log('[CroppImage] 设备信息初始化完成:', {
width: systemInfo.screenWidth,
height: systemInfo.screenHeight
})
} catch (error) {
console.error('[CroppImage] 获取设备信息失败:', error)
}
}
// 计算最大缩放限制
const calculateMaxScale = () => {
const item = itemElement.value
if (item.width <= 0 || item.height <= 0) return
// 计算容器尺寸
const newsizewidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const aspectRatio = props.sizeheight / props.sizewidth
const newsizeheight = newsizewidth * aspectRatio
// 计算最大缩放:图片缩放后不能超过屏幕宽高
const maxScaleByWidth = screenWidth.value / item.width
const maxScaleByHeight = screenHeight.value / item.height
// 取较小值作为最大缩放限制
maxScale.value = Math.min(maxScaleByWidth, maxScaleByHeight)
console.log('[CroppImage] 缩放限制计算完成:', {
minScale: minScale.value,
maxScale: maxScale.value,
imageSize: { width: item.width, height: item.height },
screenSize: { width: screenWidth.value, height: screenHeight.value }
})
}
// ==================== 辅助线管理函数 ====================
const updateGuideLines = (item : ItemElement) => {
if (!props.enableGuides) return
verticalGuides.value = []
horizontalGuides.value = []
if (item.isShowLineV == true && item.selectLineLeft != null) {
verticalGuides.value.push(item.selectLineLeft)
}
if (item.isShowLineH == true && item.selectLineTop != null) {
horizontalGuides.value.push(item.selectLineTop)
}
}
const clearGuideLines = () => {
verticalGuides.value = []
horizontalGuides.value = []
}
// ==================== 双指手势辅助函数 ====================
// 计算两点之间的距离(使用 dragTransform.uts 中的 getDistance)
const getTouchDistance = (touch1X : number, touch1Y : number, touch2X : number, touch2Y : number) : number => {
return getDistance(touch1X, touch1Y, touch2X, touch2Y)
}
// 计算两点连线的角度(用于双指旋转)- 已屏蔽
// const getTouchAngle = (touch1X : number, touch1Y : number, touch2X : number, touch2Y : number) : number => {
// const dx = touch2X - touch1X
// const dy = touch2Y - touch1Y
// return Math.atan2(dy, dx) * 180 / Math.PI
// }
// ==================== 图片拖拽事件处理函数 ====================
const handleItemTouchStart = (e : UniTouchEvent) => {
const item = itemElement.value
// 检查是否是双指操作 - 已屏蔽双指旋转功能
if (e.touches.length == 2) {
isMultiTouch.value = true
operationType.value = 'multi-touch'
// 记录初始双指距离(仅保留缩放功能)
initialDistance.value = getTouchDistance(e.touches[0].clientX, e.touches[0].clientY, e.touches[1].clientX, e.touches[1].clientY)
// initialAngle.value = getTouchAngle(e.touches[0].clientX, e.touches[0].clientY, e.touches[1].clientX, e.touches[1].clientY)
initialScale.value = item.scale
// initialRotation.value = item.angle
lastTouchTime.value = Date.now()
} else {
// 单指操作 - 立即触发拖拽(改为触摸触发)
isTouching.value = true
operationType.value = 'drag'
const pos : TouchPosition = {
x: e.touches[0].clientX,
y: e.touches[0].clientY
}
touchStartPos.value = pos
item.startX = e.touches[0].clientX
item.startY = e.touches[0].clientY
// 立即记录按下数据并触发拖拽
recordPressDownData(item)
// 显示中心辅助线
showCenterGuides.value = true
// 通知父组件进入编辑状态(隐藏其他UI元素)
emit('editingChange', true)
}
}
const handleItemTouchMove = (e : UniTouchEvent) => {
const item = itemElement.value
// 双指手势处理 - 已屏蔽旋转功能,仅保留缩放
if (e.touches.length == 2 && isMultiTouch.value) {
// 节流处理:限制更新频率,减少抖动
const now = Date.now()
if (now - lastTouchTime.value < touchThrottle) {
return
}
lastTouchTime.value = now
// 计算当前双指距离(仅用于缩放)
const currentDistance = getTouchDistance(e.touches[0].clientX, e.touches[0].clientY, e.touches[1].clientX, e.touches[1].clientY)
// const currentAngle = getTouchAngle(e.touches[0].clientX, e.touches[0].clientY, e.touches[1].clientX, e.touches[1].clientY)
// 计算缩放比例变化
const scaleChange = currentDistance / initialDistance.value
let newScale = initialScale.value * scaleChange
// 限制缩放范围:最小50%
if (newScale < minScale.value) {
newScale = minScale.value
}
// 使用平滑过渡,减少突变
const smoothFactor = 0.3
item.scale = item.scale * (1 - smoothFactor) + newScale * smoothFactor
// 已屏蔽:计算旋转角度变化
// let angleDiff = currentAngle - initialAngle.value
// let rawAngle = initialRotation.value + angleDiff
// 标准化角度到 -180 到 180 范围
// while (rawAngle > 180) rawAngle -= 360
// while (rawAngle < -180) rawAngle += 360
// 应用角度吸附效果
// let snappedAngle = applyAngleSnap(rawAngle)
// 使用平滑过渡,减少突变
// item.angle = item.angle * (1 - smoothFactor) + snappedAngle * smoothFactor
return
}
// 单指操作处理 - 直接执行拖拽(已改为触摸触发)
if (!isTouching.value) return
if (operationType.value == 'drag') {
dragMove(e, item, props.sizewidth, props.sizeheight, allGuideLines.value)
// 更新辅助线显示
updateGuideLines(item)
}
}
const handleItemTouchEnd = (e : UniTouchEvent) => {
// 如果还有触摸点,检查是否从双指变为单指
if (e.touches.length == 1 && isMultiTouch.value) {
// 从双指变为单指,立即开始拖拽(改为触摸触发)
isMultiTouch.value = false
isTouching.value = true
operationType.value = 'drag'
const item = itemElement.value
const pos : TouchPosition = {
x: e.touches[0].clientX,
y: e.touches[0].clientY
}
touchStartPos.value = pos
item.startX = e.touches[0].clientX
item.startY = e.touches[0].clientY
// 立即记录按下数据
recordPressDownData(item)
// 显示中心辅助线
showCenterGuides.value = true
// 通知父组件进入编辑状态(隐藏其他UI元素)
emit('editingChange', true)
} else if (e.touches.length == 0) {
// 所有手指都离开 - 打印图片左上角位置
printImagePosition('触摸结束')
// 通知父组件显示UI
isTouching.value = false
isMultiTouch.value = false
operationType.value = ''
touchStartPos.value = null
clearGuideLines()
// 隐藏中心辅助线
showCenterGuides.value = false
// 通知父组件退出编辑状态(显示其他UI元素)
emit('editingChange', false)
}
}
const handleItemMouseDown = (e : any) => {
isMouseDown.value = true
operationType.value = 'drag'
const item = itemElement.value
const mouseEvent = e as MouseEvent
// 记录鼠标起始位置
const pos : TouchPosition = {
x: mouseEvent.clientX,
y: mouseEvent.clientY
}
touchStartPos.value = pos
item.startX = mouseEvent.clientX
item.startY = mouseEvent.clientY
// 立即记录按下数据并触发拖拽(改为触摸触发)
recordPressDownData(item)
// 显示中心辅助线
showCenterGuides.value = true
// 通知父组件进入编辑状态(隐藏其他UI元素)
emit('editingChange', true)
}
const handleItemMouseMove = (e : any) => {
if (!isMouseDown.value) return
const item = itemElement.value
// 直接执行拖拽(已改为触摸触发)
if (operationType.value == 'drag') {
dragMove(e, item, props.sizewidth, props.sizeheight, allGuideLines.value)
updateGuideLines(item)
}
}
const handleItemMouseUp = (e : any) => {
isMouseDown.value = false
operationType.value = ''
touchStartPos.value = null
clearGuideLines()
// 隐藏中心辅助线
showCenterGuides.value = false
// 通知父组件退出编辑状态(显示其他UI元素)
emit('editingChange', false)
}
// Wrapper 层事件处理(支持图片在容器外拖动)
const onWrapperTouchStart = (e : UniTouchEvent) => {
// 检查是否点击在图片层上
const item = itemElement.value
// 计算容器尺寸和居中偏移
const newsizewidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const aspectRatio = props.sizeheight / props.sizewidth
const newsizeheight = newsizewidth * aspectRatio
const containerOffsetX = (screenWidth.value - newsizewidth) / 2
// 修正:容器的实际垂直位置 = 35% 的屏幕高度 - 容器高度的一半(因为 transform: translate(-50%, -50%))
const containerOffsetY = (screenHeight.value * 0.35) - (newsizeheight / 2)
// 获取触摸点相对于容器的坐标
const touchX = e.touches[0].clientX - containerOffsetX
const touchY = e.touches[0].clientY - containerOffsetY
// 计算图片层的中心点(相对于容器)
const itemCenterX = item.left + (item.width * item.scale) / 2
const itemCenterY = item.top + (item.height * item.scale) / 2
// 计算触摸点相对于图片中心的距离
const dx = touchX - itemCenterX
const dy = touchY - itemCenterY
// 如果图片有旋转,需要将触摸点坐标转换到图片的本地坐标系
const angleRad = (-item.angle * Math.PI) / 180 // 反向旋转
const localX = dx * Math.cos(angleRad) - dy * Math.sin(angleRad)
const localY = dx * Math.sin(angleRad) + dy * Math.cos(angleRad)
// 扩大判断范围:增加额外的触摸容差区域(400px增大容差)
const touchTolerance = 400
const halfWidth = (item.width * item.scale) / 2 + touchTolerance
const halfHeight = (item.height * item.scale) / 2 + touchTolerance
// 在图片的本地坐标系中判断是否在边界内
const isInImageBounds =
Math.abs(localX) <= halfWidth &&
Math.abs(localY) <= halfHeight
handleItemTouchStart(e)
}
const onWrapperTouchMove = (e : UniTouchEvent) => {
if (!isTouching.value && !isMultiTouch.value) return
const item = itemElement.value
// 双指手势处理 - 已屏蔽旋转功能,仅保留缩放
if (e.touches.length == 2 && isMultiTouch.value) {
// 节流处理:限制更新频率,减少抖动
const now = Date.now()
if (now - lastTouchTime.value < touchThrottle) {
return
}
lastTouchTime.value = now
// 计算当前双指距离(仅用于缩放)
const currentDistance = getTouchDistance(e.touches[0].clientX, e.touches[0].clientY, e.touches[1].clientX, e.touches[1].clientY)
// const currentAngle = getTouchAngle(e.touches[0].clientX, e.touches[0].clientY, e.touches[1].clientX, e.touches[1].clientY)
// 计算缩放比例变化
const scaleChange = currentDistance / initialDistance.value
let newScale = initialScale.value * scaleChange
// 限制缩放范围:最小50%
if (newScale < minScale.value) {
newScale = minScale.value
}
// 使用平滑过渡,减少突变
const smoothFactor = 0.3
item.scale = item.scale * (1 - smoothFactor) + newScale * smoothFactor
// 已屏蔽:计算旋转角度变化
// let angleDiff = currentAngle - initialAngle.value
// let rawAngle = initialRotation.value + angleDiff
// 标准化角度到 -180 到 180 范围
// while (rawAngle > 180) rawAngle -= 360
// while (rawAngle < -180) rawAngle += 360
// 应用角度吸附效果
// let snappedAngle = applyAngleSnap(rawAngle)
// 使用平滑过渡,减少突变
// item.angle = item.angle * (1 - smoothFactor) + snappedAngle * smoothFactor
return
}
// 单指操作处理
if (operationType.value == 'scale') {
scaleMove(e, item, props.sizewidth, props.sizeheight, allGuideLines.value)
updateGuideLines(item)
} else if (operationType.value == 'edge') {
edgeMove(e, item, props.sizewidth, props.sizeheight, allGuideLines.value)
updateGuideLines(item)
} else if (operationType.value == 'rotate') {
rotateMove(e, item)
} else if (operationType.value == 'drag') {
// 拖动处理
dragMove(e, item, props.sizewidth, props.sizeheight, allGuideLines.value)
updateGuideLines(item)
} else {
// 长按检测阶段的移动处理
handleItemTouchMove(e)
}
}
const onWrapperTouchEnd = (e : UniTouchEvent) => {
// 处理触摸结束
handleItemTouchEnd(e)
// 额外的清理工作
if (e.touches.length == 0) {
if (isTouching.value || isMultiTouch.value) {
isTouching.value = false
isMultiTouch.value = false
operationType.value = ''
currentCorner.value = ''
clearGuideLines()
// 隐藏中心辅助线
showCenterGuides.value = false
// 通知父组件退出编辑状态
emit('editingChange', false)
}
}
}
const onWrapperMouseDown = (e : any) => {
// 检查是否点击在图片层上
const item = itemElement.value
const mouseEvent = e as MouseEvent
// 计算容器尺寸和居中偏移
const newsizewidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const aspectRatio = props.sizeheight / props.sizewidth
const newsizeheight = newsizewidth * aspectRatio
const containerOffsetX = (screenWidth.value - newsizewidth) / 2
// 修正:容器的实际垂直位置 = 35% 的屏幕高度 - 容器高度的一半(因为 transform: translate(-50%, -50%))
const containerOffsetY = (screenHeight.value * 0.35) - (newsizeheight / 2)
// 获取鼠标点相对于容器的坐标
const mouseX = mouseEvent.clientX - containerOffsetX
const mouseY = mouseEvent.clientY - containerOffsetY
// 计算图片层的中心点(相对于容器)
const itemCenterX = item.left + (item.width * item.scale) / 2
const itemCenterY = item.top + (item.height * item.scale) / 2
// 计算鼠标点相对于图片中心的距离
const dx = mouseX - itemCenterX
const dy = mouseY - itemCenterY
// 如果图片有旋转,需要将鼠标点坐标转换到图片的本地坐标系
const angleRad = (-item.angle * Math.PI) / 180
const localX = dx * Math.cos(angleRad) - dy * Math.sin(angleRad)
const localY = dx * Math.sin(angleRad) + dy * Math.cos(angleRad)
// 扩大判断范围:增加额外的触摸容差区域(200px,进一步增大容差)
const touchTolerance = 200
const halfWidth = (item.width * item.scale) / 2 + touchTolerance
const halfHeight = (item.height * item.scale) / 2 + touchTolerance
// 在图片的本地坐标系中判断是否在边界内
const isInImageBounds =
Math.abs(localX) <= halfWidth &&
Math.abs(localY) <= halfHeight
// 在图片上,开始处理鼠标事件
handleItemMouseDown(e)
}
const onWrapperMouseMove = (e : any) => {
if (!isMouseDown.value) return
const item = itemElement.value
if (operationType.value == 'scale') {
scaleMove(e, item, props.sizewidth, props.sizeheight, allGuideLines.value)
updateGuideLines(item)
} else if (operationType.value == 'edge') {
edgeMove(e, item, props.sizewidth, props.sizeheight, allGuideLines.value)
updateGuideLines(item)
} else if (operationType.value == 'rotate') {
rotateMove(e, item)
} else if (operationType.value == 'drag') {
// 拖动处理
dragMove(e, item, props.sizewidth, props.sizeheight, allGuideLines.value)
updateGuideLines(item)
} else {
// 长按检测阶段的移动处理
handleItemMouseMove(e)
}
}
const onWrapperMouseUp = (e : any) => {
// 处理鼠标释放
handleItemMouseUp(e)
// 额外的清理工作
if (isMouseDown.value) {
isMouseDown.value = false
operationType.value = ''
currentCorner.value = ''
clearGuideLines()
// 隐藏中心辅助线
showCenterGuides.value = false
// 通知父组件退出编辑状态
emit('editingChange', false)
}
}
// 计算属性
// 计算容器实际显示尺寸的中心位置
const centerGuideX = computed(() => {
const newsizewidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
return newsizewidth / 2
})
const centerGuideY = computed(() => {
const newsizewidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const aspectRatio = props.sizeheight / props.sizewidth
const newsizeheight = newsizewidth * aspectRatio
return newsizeheight / 2
})
const itemLayerStyle = computed(() => {
const item = itemElement.value
return {
position: 'absolute',
left: item.left + 'px',
top: item.top + 'px',
width: item.width + 'px',
height: item.height + 'px',
transform: `scale(${item.scale}) rotate(${item.angle}deg)`,
transformOrigin: 'center center',
zIndex: item.index,
willChange: 'transform', // 优化渲染性能
transition: isMultiTouch.value ? 'none' : '' // 双指操作时禁用过渡
}
})
const imageStyle = computed(() => {
return {
width: '100%',
height: '100%',
display: 'block'
}
})
const controlLayerStyle = computed(() => {
const item = itemElement.value
return {
position: 'absolute',
left: item.left + 'px',
top: item.top + 'px',
width: item.width + 'px',
height: item.height + 'px',
transform: `scale(${item.scale}) rotate(${item.angle}deg)`,
transformOrigin: 'center center',
zIndex: item.index + 100
}
})
const canvasStyle = computed(() => {
const newsizewidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const aspectRatio = props.sizeheight / props.sizewidth
const newsizeheight = newsizewidth * aspectRatio
return {
width: `${newsizewidth}px`,
height: `${newsizeheight}px`
}
})
const maskImageStyle = computed(() => {
const newsizewidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const aspectRatio = props.sizeheight / props.sizewidth
const newsizeheight = newsizewidth * aspectRatio
return {
width: `${newsizewidth}px`,
height: `${newsizeheight}px`,
left: '50%',
top: '35%',
transform: 'translate(-50%, -50%)'
}
})
const wrapperStyle = computed(() => {
const newsizewidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const aspectRatio = props.sizeheight / props.sizewidth
const newsizeheight = newsizewidth * aspectRatio
// return {
// width: `${newsizewidth + 100}px`,
// height: `${newsizeheight + 100}px`
// }
return {
width: `100%`,
height: `100%`
}
})
const containerStyle = computed(() => {
const newsizewidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const aspectRatio = props.sizeheight / props.sizewidth
const newsizeheight = newsizewidth * aspectRatio
return {
width: `${newsizewidth}px`,
height: `${newsizeheight}px`,
position: 'absolute',
left: '50%',
top: '35%',
transform: 'translate(-50%, -50%)',
overflow: 'hidden'
}
})
// 四角控制点位置样式
const cornerWrapStyle = (corner : string) => {
const item = itemElement.value
// 当前操作的角放大1.5倍
const sizeMultiplier = currentCorner.value == corner ? 1.5 : 1
// 计算控制点的实际宽高(基础60px * 操作放大倍数)
const controlSize = 60 * sizeMultiplier
// 计算容器尺寸和居中偏移
const newsizewidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const aspectRatio = props.sizeheight / props.sizewidth
const newsizeheight = newsizewidth * aspectRatio
// 容器水平居中在 50%
const containerOffsetX = (screenWidth.value - newsizewidth) / 2
// 容器垂直居中在 35%(top: 35% + transform: translate(-50%, -50%))
const containerOffsetY = screenHeight.value * 0.35 - newsizeheight / 2
// 计算图片层的中心点(相对于容器,不包含偏移)
const itemCenterX = item.left + item.width / 2
const itemCenterY = item.top + item.height / 2
// 计算控制点相对于图片中心的偏移(未旋转、未缩放时)
let offsetX = 0
let offsetY = 0
if (corner == 'left-top') {
offsetX = -item.width / 2
offsetY = -item.height / 2
} else if (corner == 'right-top') {
offsetX = item.width / 2
offsetY = -item.height / 2
} else if (corner == 'left-bottom') {
offsetX = -item.width / 2
offsetY = item.height / 2
} else if (corner == 'right-bottom') {
offsetX = item.width / 2
offsetY = item.height / 2
}
// 先应用缩放
const scaledOffsetX = offsetX * item.scale
const scaledOffsetY = offsetY * item.scale
// 再应用旋转变换
const angleRad = (item.angle * Math.PI) / 180
const rotatedX = scaledOffsetX * Math.cos(angleRad) - scaledOffsetY * Math.sin(angleRad)
const rotatedY = scaledOffsetX * Math.sin(angleRad) + scaledOffsetY * Math.cos(angleRad)
// 计算最终位置(相对于 wrapper,加上容器居中偏移)
// 减去控制点尺寸的一半,让控制点中心对齐到角点
const finalX = itemCenterX + rotatedX + containerOffsetX - controlSize / 2
const finalY = itemCenterY + rotatedY + containerOffsetY - controlSize / 2
return {
position: 'absolute',
left: finalX + 'px',
top: finalY + 'px',
width: controlSize + 'px',
height: controlSize + 'px',
transform: `rotate(${item.angle}deg)`,
transformOrigin: 'center center',
zIndex: 99999
}
}
// 旋转控制点位置样式
const rotateWrapStyle = computed(() => {
const item = itemElement.value
// 操作时放大1.2倍
const sizeMultiplier = operationType.value == 'rotate' ? 1.2 : 1
// 旋转控制点的实际大小(基础40px * 操作放大倍数)
const controlSize = 50 * sizeMultiplier
// 计算容器尺寸和居中偏移
const newsizewidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const aspectRatio = props.sizeheight / props.sizewidth
const newsizeheight = newsizewidth * aspectRatio
// 容器水平居中在 50%
const containerOffsetX = (screenWidth.value - newsizewidth) / 2
// 容器垂直居中在 35%(top: 35% + transform: translate(-50%, -50%))
const containerOffsetY = screenHeight.value * 0.35 - newsizeheight / 2
// 计算图片层的中心点(相对于容器,不包含偏移)
const itemCenterX = item.left + item.width / 2
const itemCenterY = item.top + item.height / 2
// 旋转按钮在图片底部中心,距离底部 30px(未缩放、未旋转时)
const offsetX = 0
const offsetY = item.height / 2 + 40
// 先应用缩放
const scaledOffsetX = offsetX * item.scale
const scaledOffsetY = offsetY * item.scale
// 再应用旋转变换
const angleRad = (item.angle * Math.PI) / 180
const rotatedX = scaledOffsetX * Math.cos(angleRad) - scaledOffsetY * Math.sin(angleRad)
const rotatedY = scaledOffsetX * Math.sin(angleRad) + scaledOffsetY * Math.cos(angleRad)
// 计算最终位置(相对于 wrapper,加上容器居中偏移)
// 减去控制点尺寸的一半,让控制点中心对齐到目标位置
const finalX = itemCenterX + rotatedX + containerOffsetX - controlSize / 2
const finalY = itemCenterY + rotatedY + containerOffsetY - controlSize / 2
return {
position: 'absolute',
left: finalX + 'px',
top: finalY + 'px',
width: controlSize + 'px',
height: controlSize + 'px',
transformOrigin: 'center center',
zIndex: 99999
}
})
// 图片加载完成
const onImageLoad = (e : UniImageLoadEvent) => {
console.log('[Editor] 图片加载完成', e.detail)
const originalWidth = e.detail.width
const originalHeight = e.detail.height
if (originalWidth > 0 && originalHeight > 0) {
baseWidth.value = originalWidth
baseHeight.value = originalHeight
// 计算初始化容器尺寸(使用 0.7)
const initSizeWidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.7
const aspectRatio = props.sizeheight / props.sizewidth
const initSizeHeight = initSizeWidth * aspectRatio
// 计算实际显示容器尺寸(使用 0.51)
const displaySizeWidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const displaySizeHeight = displaySizeWidth * aspectRatio
// 根据图片的真实宽高比计算适配尺寸(基于 0.7 容器)
const imageAspectRatio = originalWidth / originalHeight
let displayWidth = initSizeWidth
let displayHeight = initSizeHeight
// 如果图片宽高比大于容器宽高比,以宽度为准
if (imageAspectRatio > (initSizeWidth / initSizeHeight)) {
displayWidth = initSizeWidth
displayHeight = initSizeWidth / imageAspectRatio
} else {
// 否则以高度为准
displayHeight = initSizeHeight
displayWidth = initSizeHeight * imageAspectRatio
}
// 计算在 0.7 容器中的居中位置
const centerLeftIn07 = (initSizeWidth - displayWidth) / 2
const centerTopIn07 = (initSizeHeight - displayHeight) / 2
// 计算从 0.7 容器到 0.51 容器的偏移量
const offsetX = (initSizeWidth - displaySizeWidth) / 2
const offsetY = (initSizeHeight - displaySizeHeight) / 2
// 最终位置 = 0.7容器居中位置 - 偏移量(使图片在 0.51 容器中居中)
const centerLeft = centerLeftIn07 - offsetX
const centerTop = centerTopIn07 - offsetY
const newItem : ItemElement = {
id: 1,
type: 0,
index: 1,
left: centerLeft,
top: centerTop,
width: displayWidth,
height: displayHeight,
scale: 1,
angle: 0,
active: false,
activeguide: false,
activescale: false,
activehorn: false,
activeedge: false,
initialWidth: displayWidth,
initialHeight: displayHeight
}
itemElement.value = newItem
// 初始化辅助线
const guides : GuideLine[] = [
{ type: 'v', left: 0, top: 0 } as GuideLine,
{ type: 'v', left: props.sizewidth, top: 0 } as GuideLine,
{ type: 'v', left: props.sizewidth / 2, top: 0 } as GuideLine,
{ type: 'h', left: 0, top: 0 } as GuideLine,
{ type: 'h', left: 0, top: props.sizeheight } as GuideLine,
{ type: 'h', left: 0, top: props.sizeheight / 2 } as GuideLine
]
allGuideLines.value = guides
// 计算缩放限制
calculateMaxScale()
// 图片初始化完成后,自动显示控制点
isSelected.value = true
itemElement.value.active = true
showCorners.value = true
console.log('[Editor] 图片初始化完成:', {
原图尺寸: { width: originalWidth, height: originalHeight },
图片宽高比: imageAspectRatio,
容器尺寸: { width: displaySizeWidth, height: displaySizeHeight },
显示尺寸: { width: displayWidth, height: displayHeight },
位置: { left: itemElement.value.left, top: itemElement.value.top }
})
}
}
// 图片点击事件
const onImageClick = () => {
// 不再通过点击切换选中状态
// 控制点的显示/隐藏由长按操作控制
console.log('[Editor] 图片点击(不切换状态)')
}
// 四角缩放事件
const onCornerStart = (e : UniTouchEvent, corner : string) => {
operationType.value = 'scale'
isTouching.value = true
currentCorner.value = corner
// 显示中心辅助线
showCenterGuides.value = true
// 通知父组件进入编辑状态(隐藏其他UI元素)
emit('editingChange', true)
const item = itemElement.value
item.typetext = corner
item.touchX = e.touches[0].clientX
item.touchY = e.touches[0].clientY
recordPressDownData(item)
}
const onCornerEnd = () => {
currentCorner.value = ''
// 隐藏中心辅助线
showCenterGuides.value = false
// 通知父组件退出编辑状态(显示其他UI元素)
emit('editingChange', false)
}
//四边拉伸事件
const onCornerMouseStart = (e : any, corner : string) => {
operationType.value = 'scale'
isMouseDown.value = true
currentCorner.value = corner
// 显示中心辅助线
showCenterGuides.value = true
// 通知父组件进入编辑状态(隐藏其他UI元素)
emit('editingChange', true)
const item = itemElement.value
item.typetext = corner
const mouseEvent = e as MouseEvent
item.touchX = mouseEvent.clientX
item.touchY = mouseEvent.clientY
recordPressDownData(item)
}
// 旋转事件
const onRotateStart = (e : UniTouchEvent) => {
operationType.value = 'rotate'
isTouching.value = true
// 显示中心辅助线
showCenterGuides.value = true
// 通知父组件进入编辑状态(隐藏其他UI元素)
emit('editingChange', true)
const item = itemElement.value
item.touchX = e.touches[0].clientX
item.touchY = e.touches[0].clientY
// 计算容器尺寸和居中偏移
const newsizewidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const aspectRatio = props.sizeheight / props.sizewidth
const newsizeheight = newsizewidth * aspectRatio
const containerOffsetX = (screenWidth.value - newsizewidth) / 2
const containerOffsetY = (screenHeight.value - newsizeheight) / 2
// 记录中心位置(考虑缩放和容器偏移)
const centerX = item.left + containerOffsetX + (item.width * item.scale) / 2
const centerY = item.top + containerOffsetY + (item.height * item.scale) / 2
item.centerPos = { x: centerX, y: centerY }
// 记录初始角度
item.anglePre = item.angle
recordPressDownData(item)
}
const onRotateEnd = () => {
operationType.value = ''
isTouching.value = false
// 隐藏中心辅助线
showCenterGuides.value = false
// 通知父组件退出编辑状态(显示其他UI元素)
emit('editingChange', false)
console.log('[Editor] 旋转结束,通知父组件显示UI,最终角度:', itemElement.value.angle)
}
const onRotateMouseStart = (e : any) => {
operationType.value = 'rotate'
isMouseDown.value = true
// 显示中心辅助线
showCenterGuides.value = true
// 通知父组件进入编辑状态(隐藏其他UI元素)
emit('editingChange', true)
const item = itemElement.value
const mouseEvent = e as MouseEvent
item.touchX = mouseEvent.clientX
item.touchY = mouseEvent.clientY
// 计算容器尺寸和居中偏移
const newsizewidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const aspectRatio = props.sizeheight / props.sizewidth
const newsizeheight = newsizewidth * aspectRatio
const containerOffsetX = (screenWidth.value - newsizewidth) / 2
const containerOffsetY = (screenHeight.value - newsizeheight) / 2
// 记录中心位置(考虑缩放和容器偏移)
const centerX = item.left + containerOffsetX + (item.width * item.scale) / 2
const centerY = item.top + containerOffsetY + (item.height * item.scale) / 2
item.centerPos = { x: centerX, y: centerY }
// 记录初始角度
item.anglePre = item.angle
recordPressDownData(item)
}
// ==================== 取消选择 ====================
const cancelSelection = () => {
// 不再通过点击空白区域取消选择
// 控制点始终显示,只在长按操作时隐藏
}
const printAllInfo = () => {
const item = itemElement.value
// 计算缩放比例(显示尺寸相对于原始尺寸的比例)
const newsizewidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const scaleRatio = props.sizewidth / newsizewidth
// 🔧 调试参数:根据缩放方向使用不同的偏移系数
// SCALE_DOWN_ADJUSTMENT: 缩小时的偏移系数(scale < 1)
// SCALE_UP_ADJUSTMENT: 放大时的偏移系数(scale > 1)
const SCALE_DOWN_ADJUSTMENT = 1 // 缩小时使用,0.92 位置正确
const SCALE_UP_ADJUSTMENT = 1 // 放大时使用,根据实际情况调整 1.206 小左上角 大右下角
// 根据当前缩放值选择合适的偏移系数
const OFFSET_ADJUSTMENT = item.scale < 1 ? SCALE_DOWN_ADJUSTMENT : SCALE_UP_ADJUSTMENT
// 计算图片在容器中的实际显示尺寸(考虑 scale)
const actualDisplayWidth = item.width * item.scale
const actualDisplayHeight = item.height * item.scale
// 计算图片左上角的实际位置(考虑 scale 导致的中心偏移)
// 因为 transform-origin 是 center center,缩放会导致左上角位置变化
const scaleOffset = (1 - item.scale) / 2 * OFFSET_ADJUSTMENT
const actualLeft = item.left + item.width * scaleOffset
const actualTop = item.top + item.height * scaleOffset
// 将显示坐标和尺寸转换回原始尺寸
const originalX = actualLeft * scaleRatio
const originalY = actualTop * scaleRatio
const originalWidth = actualDisplayWidth * scaleRatio
const originalHeight = actualDisplayHeight * scaleRatio
// 构造完整的裁剪信息对象(使用原始尺寸)
const cropInfo = {
imageX: originalX,
imageY: originalY,
displayWidth: originalWidth,
displayHeight: originalHeight,
imageScale: 1,
imageRotation: item.angle,
canvasWidth: props.sizewidth,
canvasHeight: props.sizeheight
} as UTSJSONObject
console.log('最终裁剪信息:', cropInfo)
// 通过 emit 传递给父组件
emit('cropData', cropInfo)
}
// 重置图片到初始状态
const resetImage = () => {
const item = itemElement.value
// 重置为初始尺寸和位置
if (item.initialWidth != null && item.initialHeight != null) {
item.width = item.initialWidth
item.height = item.initialHeight
}
// 计算初始化容器尺寸(使用 0.7)
const initSizeWidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.7
const aspectRatio = props.sizeheight / props.sizewidth
const initSizeHeight = initSizeWidth * aspectRatio
// 计算实际显示容器尺寸(使用 0.51)
const displaySizeWidth = (screenWidth.value / props.sizewidth) * props.sizewidth * 0.51
const displaySizeHeight = displaySizeWidth * aspectRatio
// 计算在 0.7 容器中的居中位置
const centerLeftIn07 = (initSizeWidth - item.width) / 2
const centerTopIn07 = (initSizeHeight - item.height) / 2
// 计算从 0.7 容器到 0.51 容器的偏移量
const offsetX = (initSizeWidth - displaySizeWidth) / 2
const offsetY = (initSizeHeight - displaySizeHeight) / 2
// 重置位置到中心:让图片中心点对齐到 0.51 容器的中心
item.left = centerLeftIn07 - offsetX
item.top = centerTopIn07 - offsetY
// 重置缩放和旋转
item.scale = 1
item.angle = 0
// 清除选中状态和辅助线
isSelected.value = false
item.active = false
clearGuideLines()
}
// 暴露方法给父组件
defineExpose({
printAllInfo,
resetImage
})
const stopWatchIsCropping = watch(() : boolean => props.isCropping, (newVal : boolean, prevVal : boolean) => {
if (newVal) {
printAllInfo()
}
})
// 组件初始化
initDeviceInfo()
</script>
<style>
.editor-root {
overflow: visible;
height: 100%;
}
.editor-wrapper {
position: relative;
overflow: visible;
z-index: 1;
}
.editor-container {
position: absolute;
overflow: hidden;
background: #f5f5f5;
z-index: 1;
pointer-events: none;
}
.editor-mask-image {
position: absolute;
z-index: 10;
pointer-events: none;
}
/* 辅助线样式 */
.guide-line {
position: absolute;
pointer-events: none;
z-index: 9999;
}
.guide-line-v {
width: 1px;
height: 100%;
border-left: 1px dashed #fd3da0;
}
.guide-line-h {
width: 100%;
height: 1px;
border-top: 1px dashed #fd3da0;
}
.guide-line-active {
background: #F56364;
border-color: #F56364;
}
/* 图片层 */
.item-layer {
pointer-events: none;
position: relative;
z-index: 1;
}
.editor-image {
width: 100%;
height: 100%;
}
/* 控制层 */
.control-layer {
pointer-events: none;
}
/* 四角控制点 - 与 index_phone.vue 一致 */
.frame-left-top-wrap,
.frame-right-top-wrap,
.frame-left-bottom-wrap,
.frame-right-bottom-wrap {
z-index: 99999 !important;
pointer-events: auto;
position: absolute;
display: flex;
align-items: center;
justify-content: center;
}
.corner-inner {
width: 30rpx;
height: 30rpx;
transition: border-color 0.2s;
}
.frame-left-top-wrap .corner-inner {
border-left: 2rpx solid #fd3da0;
border-top: 2rpx solid #fd3da0;
}
.frame-right-top-wrap .corner-inner {
border-right: 2rpx solid #fd3da0;
border-top: 2rpx solid #fd3da0;
}
.frame-left-bottom-wrap .corner-inner {
border-left: 2rpx solid #fd3da0;
border-bottom: 2rpx solid #fd3da0;
}
.frame-right-bottom-wrap .corner-inner {
border-right: 2rpx solid #fd3da0;
border-bottom: 2rpx solid #fd3da0;
}
/* 四边控制点 */
.control-edge {
position: absolute;
background: #fd3da0;
pointer-events: auto;
z-index: 9;
}
.control-edge-l {
top: 50%;
left: -4rpx;
width: 8rpx;
height: 40rpx;
transform: translateY(-50%);
}
.control-edge-r {
top: 50%;
right: -4rpx;
width: 8rpx;
height: 40rpx;
transform: translateY(-50%);
}
.control-edge-t {
top: -4rpx;
left: 50%;
width: 40rpx;
height: 8rpx;
transform: translateX(-50%);
}
.control-edge-b {
bottom: -4rpx;
left: 50%;
width: 40rpx;
height: 8rpx;
transform: translateX(-50%);
}
/* 旋转控制点 */
.control-rotate {
position: absolute;
bottom: -60rpx;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 40rpx;
pointer-events: auto;
z-index: 10;
}
.control-rotate-icon {
width: 100%;
height: 100%;
background: #fd3da0;
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
}
/* Canvas */
.cropping-canvas {
position: absolute;
top: -9999rpx;
left: -9999rpx;
z-index: -1;
}
/* 旋转控制点 */
.rotate,
.rotate-red {
width: 56rpx;
height: 56rpx;
position: absolute;
z-index: 99999 !important;
display: flex;
align-items: center;
justify-content: center;
}
.rotate-icon {
width: 100%;
height: 100%;
}
.rotate-red {
opacity: 0.8;
}
</style>
\ No newline at end of file
<template>
<view class="h5-camera-container">
<!-- 视频流显示 -->
<view v-if="!capturedImage" class="video-wrapper" v-html="videoHtml"></view>
<!-- Canvas用于捕获照片 -->
<canvas class="camera-canvas" id="photoCanvas"></canvas>
<!-- 已拍摄的照片 -->
<image v-if="capturedImage" class="captured-image" :src="capturedImage" mode="aspectFill"></image>
<!-- 重拍按钮 -->
<view v-if="capturedImage" class="refresh-host" @click="retake">
<image class="refresh-icon" src="/static/icon_refresh.png" mode="scaleToFill"></image>
</view>
<!-- 人像预览框 -->
<image class="preview-frame" src="/static/silhouette.png" mode="scaleToFill"></image>
<!-- 倒计时显示 -->
<view v-if="countdownVisible" class="countdown-overlay">
<text class="countdown-number">{{ countdownNumber }}</text>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, onUnmounted } from 'vue'
const capturedImage = ref<string>('')
const countdownVisible = ref<boolean>(false)
const countdownNumber = ref<number>(5)
let countdownTimerId : number | null = null
let mediaStream : any = null
const videoHtml = ref<string>('<video class="camera-video" autoplay playsinline muted webkit-playsinline x5-playsinline style="width:100%;height:100%;object-fit:cover;transform:scaleX(-1);"></video>')
// 定义 props
const props = defineProps<{
imageSrc ?: string
}>()
// 定义 emits
const emit = defineEmits<{
captured : [imageSrc: string]
retake : []
}>()
onMounted(() => {
// 如果传入了 imageSrc,直接显示
if (props.imageSrc) {
capturedImage.value = props.imageSrc
} else {
// 延迟启动摄像头,确保 DOM 已渲染
setTimeout(() => {
startCamera()
}, 300)
}
})
onUnmounted(() => {
stopCamera()
if (countdownTimerId != null) {
clearInterval(countdownTimerId as number)
countdownTimerId = null
}
})
// 启动摄像头
const startCamera = () => {
// #ifdef H5
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
uni.showToast({
title: '浏览器不支持摄像头',
icon: 'none'
})
return
}
navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // 后置摄像头
width: { ideal: 480 },
height: { ideal: 640 }
},
audio: false
})
.then((stream) => {
mediaStream = stream
// 使用原生 DOM 操作
const videoElement = document.querySelector('.camera-video') as HTMLVideoElement
if (videoElement) {
videoElement.srcObject = stream
// 设置视频属性
videoElement.setAttribute('autoplay', 'true')
videoElement.setAttribute('playsinline', 'true')
videoElement.setAttribute('muted', 'true')
// 尝试播放
const playPromise = videoElement.play()
if (playPromise !== undefined) {
playPromise.catch((err) => {
console.error('视频播放失败:', err)
})
}
} else {
console.error('未找到 video 元素')
}
})
.catch((error) => {
console.error('无法访问摄像头:', error)
let errorMsg = '无法访问摄像头'
if (error.name === 'NotAllowedError') {
errorMsg = '摄像头权限被拒绝'
} else if (error.name === 'NotFoundError') {
errorMsg = '未找到摄像头设备'
}
uni.showToast({
title: errorMsg,
icon: 'none'
})
})
// #endif
}
// 停止摄像头
const stopCamera = () => {
// #ifdef H5
if (mediaStream) {
const tracks = mediaStream.getTracks()
tracks.forEach((track : any) => {
track.stop()
})
mediaStream = null
}
// #endif
}
// 拍照
const takePhoto = () => {
// #ifdef H5
const videoElement = document.querySelector('.camera-video') as HTMLVideoElement
const canvasElement = document.getElementById('photoCanvas') as HTMLCanvasElement
if (!videoElement || !canvasElement) {
console.error('未找到 video 或 canvas 元素')
uni.showToast({
title: '拍照失败',
icon: 'none'
})
return
}
// 检查视频是否准备好
if (videoElement.readyState !== videoElement.HAVE_ENOUGH_DATA) {
console.error('视频未准备好')
uni.showToast({
title: '请稍候再试',
icon: 'none'
})
return
}
const context = canvasElement.getContext('2d')
if (!context) {
console.error('无法获取 canvas context')
return
}
// 设置 canvas 尺寸与视频一致
const width = videoElement.videoWidth || 480
const height = videoElement.videoHeight || 640
canvasElement.width = width
canvasElement.height = height
// 水平翻转画布(修复镜像问题)
context.translate(width, 0)
context.scale(-1, 1)
// 绘制当前视频帧到 canvas
context.drawImage(videoElement, 0, 0, width, height)
// 重置变换
context.setTransform(1, 0, 0, 1, 0, 0)
// 将 canvas 转换为图片
const dataUrl = canvasElement.toDataURL('image/jpeg', 0.9)
capturedImage.value = dataUrl
emit('captured', dataUrl)
stopCamera()
// #endif
}
// 重拍
const retake = () => {
capturedImage.value = ''
emit('retake')
// 延迟启动摄像头
setTimeout(() => {
startCamera()
}, 100)
}
// 开始倒计时拍照
const startCountdown = () => {
if (countdownVisible.value || capturedImage.value) {
return
}
countdownNumber.value = 5
countdownVisible.value = true
if (countdownTimerId != null) {
clearInterval(countdownTimerId as number)
countdownTimerId = null
}
countdownTimerId = setInterval(() => {
if (countdownNumber.value > 1) {
countdownNumber.value = countdownNumber.value - 1
} else {
if (countdownTimerId != null) {
clearInterval(countdownTimerId as number)
countdownTimerId = null
}
countdownVisible.value = false
takePhoto()
}
}, 1000) as number
}
// 获取已拍摄的图片
const getCapturedImage = () : string => {
return capturedImage.value
}
// 暴露方法给父组件
defineExpose({
startCountdown,
getCapturedImage,
retake
})
</script>
<style scoped>
.h5-camera-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.video-wrapper {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.camera-video {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-canvas {
position: absolute;
left: -9999px;
top: -9999px;
visibility: hidden;
pointer-events: none;
}
.captured-image {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.refresh-host {
position: absolute;
right: 10rpx;
top: 10rpx;
width: 52rpx;
height: 52rpx;
background: #FFFFFF;
border-radius: 26rpx;
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
}
.refresh-icon {
width: 26rpx;
height: 26rpx;
}
.preview-frame {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 506rpx;
height: 560rpx;
pointer-events: none;
z-index: 5;
}
.countdown-overlay {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 20;
}
.countdown-number {
font-weight: bold;
font-size: 180rpx;
color: #FFFFFF;
text-shadow: 0 0 20rpx rgba(0, 0, 0, 0.5);
}
</style>
\ No newline at end of file
<template>
<view class="gradient-header">
<view class="back-btn" hover-class="hover_btn" @click="onBack">
<image class="back-icon" src="/static/icon_back.png" mode="aspectFill"></image>
</view>
<text class="header-title">
<slot>{{ title }}</slot>
</text>
<view style="width: 50rpx;"></view>
</view>
</template>
<script setup lang="uts">
type Props = {
title : string
}
const props = withDefaults(defineProps<Props>(), {
title: ''
})
const emit = defineEmits<{
(e : 'back') : void
}>()
const onBack = () : void => {
// 触发返回事件,由父组件决定返回逻辑,避免重复返回
emit('back')
}
</script>
<style>
.gradient-header {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0 36rpx;
padding-top: 56rpx;
height: 25%;
background: linear-gradient(to bottom, #FC81C0, #FFFFFF);
}
.back-btn {
width: 50rpx;
height: 50rpx;
border-radius: 25rpx;
background-color: rgba(255, 255, 255, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.back-icon {
width: 45rpx;
height: 45rpx;
}
.header-title {
font-weight: bold;
font-size: 25rpx;
color: #000000;
}
</style>
\ No newline at end of file
<template>
<view v-if="visible" class="confirm-overlay" :class="{ 'confirm-overlay-enter': showAnimation }" @click="onOverlay">
<view class="confirm-overlay__content" @click.stop>
<view class="confirm-fixed" :class="{ 'confirm-scale-enter': showAnimation }">
<view class="confirm-container">
<text class="confirm-title">{{ title }}</text>
<view class="confirm-content-wrap">
<text class="confirm-content">{{ content }}</text>
</view>
<view class="confirm-actions">
<view v-if="showCancel" class="btn-cancel" hover-class="hover_btn" @click="onCancel">
<text class="btn-text-cancel">{{ cancelText }}</text>
</view>
<view class="btn-confirm" hover-class="hover_btn" @click="onConfirm">
<text class="btn-text-confirm">{{ confirmText }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
type PopupConfirmProps = {
visible : boolean
title : string
content : string
confirmText : string
cancelText : string
closeOnOverlay : boolean
showCancel : boolean
}
type PopupConfirmEmits = {
(e : 'confirm') : void
(e : 'cancel') : void
}
const props = withDefaults(defineProps<PopupConfirmProps>(), {
visible: false,
title: '提示',
content: '',
confirmText: '确定',
cancelText: '取消',
closeOnOverlay: true,
showCancel: true
})
const emit = defineEmits<PopupConfirmEmits>()
const showAnimation = ref(false)
// 进入时触发动画,退出时复位
watch(() : boolean => props.visible, (val : boolean) : void => {
if (val == true) {
nextTick(() => {
showAnimation.value = true
})
} else {
showAnimation.value = false
}
})
const onCancel = () : void => {
emit('cancel')
}
const onConfirm = () : void => {
emit('confirm')
}
const onOverlay = () : void => {
if (props.closeOnOverlay == true) {
emit('cancel')
}
}
</script>
<style>
.confirm-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0);
z-index: 10000;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.3s ease-out;
}
.confirm-overlay-enter {
background-color: rgba(0, 0, 0, 0.5);
}
.confirm-overlay__content {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.confirm-fixed {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
opacity: 0;
transform: scale(0.7) translateY(50rpx);
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.confirm-scale-enter {
opacity: 1;
transform: scale(1) translateY(0);
}
.confirm-container {
width: 566rpx;
background-color: #FFFFFF;
border-radius: 16rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
padding: 24rpx;
box-sizing: border-box;
}
.confirm-title {
font-weight: bold;
font-size: 32rpx;
color: #21061A;
}
.confirm-content-wrap {
margin-top: 20rpx;
width: 100%;
max-width: 518rpx;
display: flex;
justify-content: center;
align-items: center;
box-sizing: border-box;
}
.confirm-content {
font-weight: 400;
font-size: 26rpx;
color: #333333;
text-align: center;
padding: 30rpx 0;
width: 100%;
white-space: pre-wrap;
line-height: 1.5;
}
.confirm-actions {
margin-top: 36rpx;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.btn-cancel {
width: 210rpx;
height: 72rpx;
background-color: #F4F4F5;
border-radius: 12rpx;
margin-right: 20rpx;
display: flex;
justify-content: center;
align-items: center;
}
.btn-confirm {
width: 210rpx;
height: 72rpx;
background-color: #fd3da0;
border-radius: 12rpx;
display: flex;
justify-content: center;
align-items: center;
}
.btn-text-cancel {
color: #333333;
font-size: 26rpx;
font-weight: 700;
}
.btn-text-confirm {
color: #FFFFFF;
font-size: 26rpx;
font-weight: 700;
}
</style>
\ No newline at end of file
<template>
<view v-if="display" class="sheet-root" @touchmove.stop="handlePanelTouch">
<view class="sheet-mask" :class="maskClass" @click="handleMaskClick"></view>
<view class="sheet-panel-wrapper">
<view class="sheet-panel" :class="panelClass" @click.stop="handlePanelClick">
<view class="sheet-handle"></view>
<view v-if="showHeader" class="sheet-header">
<view class="sheet-header-left">
<slot name="header">
<text v-if="showTitle" class="sheet-title">{{ title }}</text>
</slot>
</view>
<view v-if="showClose" class="sheet-close-area" @click.stop="handleCloseClick">
<image class="sheet-close-icon" src="/static/icon_close.png" mode="aspectFit"></image>
</view>
</view>
<view class="sheet-content">
<scroll-view scroll-y="true" style="flex:1">
<!-- 二维码区域 -->
<view class="qr-section">
<view class="qr-code-container" v-if="qrcode">
<image :src="qrcode" class="qr-code-image" mode="aspectFit" />
</view>
<view class="qr-loading" v-else>
<text class="loading-text">二维码生成中...</text>
</view>
<text class="qr-instruction">手机扫码即可分享</text>
</view>
<slot></slot>
</scroll-view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, watchEffect, nextTick, computed, useSlots, onBeforeUnmount } from 'vue'
import { shareQrcode, type ResponseData } from '../../utils/reques.uts'
const TRANSITION_DURATION: number = 260
type ActionSheetProps = {
visible: boolean
title: string
showClose: boolean
closeOnOverlay: boolean
worksId?: number
}
type ActionSheetEmits = {
(e: 'update:visible', value: boolean): void
(e: 'open'): void
(e: 'closed'): void
(e: 'close'): void
}
const props = withDefaults(defineProps<ActionSheetProps>(), {
visible: false,
title: '',
showClose: true,
closeOnOverlay: true,
worksId: 0
})
const emit = defineEmits<ActionSheetEmits>()
const qrcode = ref('')
const getQrcode = async (worksId: number): Promise<void> => {
try {
const res = await shareQrcode(worksId) as ResponseData
if (res.code == 0 && res.data != null) {
const dataObj = res.data as UTSJSONObject
const base64Image = dataObj.get('base_64_image')
if (base64Image != null) {
qrcode.value = base64Image as string
}
} else {
console.log('获取二维码失败:', res.message)
}
} catch (error) {
console.log('请求二维码失败:', error)
}
}
const slots = useSlots()
const display = ref<boolean>(false)
const maskVisible = ref<boolean>(false)
const panelVisible = ref<boolean>(false)
let hideTimer: number = -1
let openTimer: number = -1
const showTitle = computed((): boolean => {
if (props.title.length > 0) {
return true
}
return false
})
const hasHeaderSlot = computed((): boolean => {
const headerSlot = slots['header']
if (headerSlot != null) {
return true
}
return false
})
const showHeader = computed((): boolean => {
if (hasHeaderSlot.value == true) {
return true
}
if (showTitle.value == true) {
return true
}
if (props.showClose == true) {
return true
}
return false
})
const maskClass = computed((): string => {
if (maskVisible.value == true) {
return 'sheet-mask--show'
}
return 'sheet-mask--hide'
})
const panelClass = computed((): string => {
if (panelVisible.value == true) {
return 'sheet-panel--show'
}
return ''
})
const clearTimers = (): void => {
if (hideTimer != -1) {
clearTimeout(hideTimer)
hideTimer = -1
}
if (openTimer != -1) {
clearTimeout(openTimer)
openTimer = -1
}
}
const openPanel = (): void => {
clearTimers()
display.value = true
maskVisible.value = true
nextTick(() => {
if (props.visible == true) {
panelVisible.value = true
}
})
openTimer = setTimeout(() => {
emit('open')
openTimer = -1
}, TRANSITION_DURATION) as number
}
const closePanel = (): void => {
clearTimers()
panelVisible.value = false
maskVisible.value = false
hideTimer = setTimeout(() => {
display.value = false
emit('closed')
hideTimer = -1
}, TRANSITION_DURATION) as number
}
const handleMaskClick = (): void => {
if (props.closeOnOverlay == true) {
emit('update:visible', false)
emit('close')
}
}
const handleCloseClick = (): void => {
emit('update:visible', false)
emit('close')
}
const handlePanelClick = (): void => {}
const handlePanelTouch = (): void => {}
watchEffect(() => {
const visible = props.visible
if (visible == true) {
openPanel()
// 打开时请求二维码
if (props.worksId > 0) {
qrcode.value = ''
getQrcode(props.worksId)
}
} else {
if (display.value == true) {
closePanel()
}
}
})
onBeforeUnmount(() => {
clearTimers()
})
</script>
<style>
.sheet-root {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999;
}
.sheet-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.45);
opacity: 0;
transition: opacity 0.26s ease;
}
.sheet-mask--show {
opacity: 1;
}
.sheet-mask--hide {
opacity: 0;
}
.sheet-panel-wrapper {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
}
.sheet-panel {
width: 100%;
max-height: 600rpx;
background-color: #FFFFFF;
border-top-left-radius: 28rpx;
border-top-right-radius: 28rpx;
padding: 24rpx 36rpx 36rpx;
box-shadow: 0px -8rpx 32rpx rgba(0, 0, 0, 0.08);
transform: translateY(100%);
transition: transform 0.26s ease;
display: flex;
flex-direction: column;
}
.sheet-panel--show {
transform: translateY(0%);
}
.sheet-handle {
width: 110rpx;
height: 10rpx;
border-radius: 5rpx;
background-color: rgba(0, 0, 0, 0.12);
align-self: center;
margin-bottom: 24rpx;
}
.sheet-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.sheet-header-left {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.sheet-title {
margin-left: 50rpx;
font-size: 30rpx;
color: #1F1F1F;
font-weight: bold;
}
.sheet-close-area {
width: 48rpx;
height: 48rpx;
border-radius: 24rpx;
background-color: rgba(0, 0, 0, 0.05);
display: flex;
justify-content: center;
align-items: center;
}
.sheet-close-icon {
width: 28rpx;
height: 28rpx;
}
.sheet-content {
flex: 1;
overflow: hidden;
}
/* 二维码区域 */
.qr-section {
padding: 40rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.qr-code-container {
width: 300rpx;
height: 300rpx;
display: flex;
justify-content: center;
align-items: center;
}
.qr-code-image {
width: 100%;
height: 100%;
}
.qr-loading {
width: 300rpx;
height: 300rpx;
display: flex;
justify-content: center;
align-items: center;
}
.loading-text {
font-size: 28rpx;
color: #999;
}
.qr-instruction {
margin-top: 30rpx;
font-weight: 400;
font-size: 20rpx;
color: #A9A9B2;
text-align: center;
}
</style>
/**
* 图形变换工具类
* 支持拖拽、缩放、旋转、磁性吸附等功能
*/
// 元素项类型定义
export type ItemElement = {
id: number
type: number // 0:图片 1:文字 2:贴纸
index: number // 层级
// 位置和尺寸
left: number
top: number
width: number
height: number
// 变换属性
scale: number
angle: number
// 状态标识
active: boolean
activeguide: boolean // 辅助线
activescale: boolean // 旋转控制
activehorn: boolean // 四角控制
activeedge: boolean // 四边控制
// 操作类型标识
typetext?: string // 'left-top', 'right-bottom', 'left', 'right', 'top', 'bottom', 'rotate'
// 触摸/鼠标数据
touchX?: number
touchY?: number
touchMoveX?: number
touchMoveY?: number
startX?: number
startY?: number
// 缓存数据
lastElmX?: number
lastElmY?: number
lastElmW?: number
lastElmH?: number
lastScale?: number
// 初始尺寸(用于计算最小缩放限制)
initialWidth?: number
initialHeight?: number
// 临时计算数据
elmX?: number
elmY?: number
elmW?: number
elmH?: number
newRotate?: number
fixedX?: number
fixedY?: number
centerPos?: UTSJSONObject
x?: number
y?: number
oScale?: number
// 辅助线显示
isShowLineV?: boolean
isShowLineH?: boolean
selectLineLeft?: number
selectLineTop?: number
// 其他属性
isDrag?: boolean
content?: string
anglePre?: number
}
// 辅助线数据类型
export type GuideLine = {
type: string // 'h' 或 'v'
left: number
top: number
}
/**
* 计算两点间的角度
*/
export function getAngle(x: number, y: number): number {
let theta = Math.atan2(y, x)
theta = Math.round((180 / Math.PI) * theta)
if (theta < 0) theta = 360 + theta
return theta
}
/**
* 计算两点间的距离
*/
export function getDistance(x1: number, y1: number, x2: number, y2: number): number {
const dx = x2 - x1
const dy = y2 - y1
return Math.sqrt(dx * dx + dy * dy)
}
/**
* 记录按下时的数据
*/
export function recordPressDownData(item: ItemElement) {
item.lastElmX = item.left
item.lastElmY = item.top
item.lastElmW = item.width
item.lastElmH = item.height
item.lastScale = item.scale
let rotate = Math.abs(item.angle) % 360
if (item.angle < 0) {
let multiple = Math.ceil(Math.abs(item.angle) / 360)
rotate = item.angle + multiple * 360
}
if (rotate == 360) {
rotate = 0
}
item.newRotate = rotate
// 记录固定点坐标
let fixed: UTSJSONObject | null = null
if (item.typetext == 'left-top') {
fixed = { x: 'right', y: 'bottom' } as UTSJSONObject
} else if (item.typetext == 'left-bottom' || item.typetext == 'left') {
fixed = { x: 'right', y: 'top' } as UTSJSONObject
} else if (item.typetext == 'right-top' || item.typetext == 'top') {
fixed = { x: 'left', y: 'bottom' } as UTSJSONObject
} else if (item.typetext == 'right-bottom' || item.typetext == 'right' || item.typetext == 'bottom') {
fixed = { x: 'left', y: 'top' } as UTSJSONObject
}
if (fixed != null) {
let rect = {
top: item.top,
right: item.left + item.width,
bottom: item.top + item.height,
left: item.left
}
let fixedCoordinate = rotatedPoint(rect, rotate, fixed)
item.fixedX = fixedCoordinate['x'] as number
item.fixedY = fixedCoordinate['y'] as number
}
}
/**
* 计算旋转后的坐标点
*/
export function rotatedPoint(rect: UTSJSONObject, rotate: number, point: UTSJSONObject): UTSJSONObject {
let top = rect['top'] as number
let right = rect['right'] as number
let bottom = rect['bottom'] as number
let left = rect['left'] as number
let rad = (Math.PI / 180) * rotate
let cos = Math.cos(rad)
let sin = Math.sin(rad)
let originX = (right - left) / 2 + left
let originY = (bottom - top) / 2 + top
let pointX = point['x'] as string
let pointY = point['y'] as string
let x = rect[pointX] as number
let y = rect[pointY] as number
x -= originX
y -= originY
return {
x: Math.floor((x * cos - y * sin + originX) * 10000) / 10000,
y: Math.floor((x * sin + y * cos + originY) * 10000) / 10000
}
}
/**
* 计算固定点位置
*/
export function fixedTo(item: ItemElement, fixed: UTSJSONObject, rotate: number): UTSJSONObject {
let elmX = item.elmX as number
let elmY = item.elmY as number
let elmW = item.elmW as number
let elmH = item.elmH as number
let rect = {
top: elmY,
right: elmX + elmW,
bottom: elmY + elmH,
left: elmX
}
let rotated = rotatedPoint(rect, rotate, fixed)
let fixedX = rotated['x'] as number
let fixedY = rotated['y'] as number
let dX = (item.fixedX as number) - fixedX
let dY = (item.fixedY as number) - fixedY
return {
x: elmX + dX,
y: elmY + dY
}
}
/**
* 四角缩放移动事件
*/
export function scaleMove(e: any, item: ItemElement, designWidth: number, designHeight: number, allLineListData: GuideLine[] = []) {
// 状态初始化
item.isShowLineV = false
item.isShowLineH = false
item.active = false
item.activeguide = false
item.activescale = false
item.activehorn = true
item.activeedge = false
// 计算最小尺寸:初始尺寸的50%
const initialWidth = item.initialWidth != null ? item.initialWidth : item.lastElmW as number
const initialHeight = item.initialHeight != null ? item.initialHeight : item.lastElmH as number
const minWidth = initialWidth * 0.5
const minHeight = initialHeight * 0.5
// 取消最大尺寸限制,允许无限放大
// const maxWidth = 800
// const maxHeight = 800
// 获取触摸位置
const touchEvent = e as UniTouchEvent
if (touchEvent.touches.length > 0) {
item.touchMoveX = touchEvent.touches[0].clientX
item.touchMoveY = touchEvent.touches[0].clientY
} else {
const mouseEvent = e as MouseEvent
item.touchMoveX = mouseEvent.clientX
item.touchMoveY = mouseEvent.clientY
}
let touchMoveX = item.touchMoveX as number
let touchMoveY = item.touchMoveY as number
let touchX = item.touchX as number
let touchY = item.touchY as number
let newRotate = item.newRotate as number
let width = touchMoveX - touchX
let height = touchMoveY - touchY
let c = Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2))
let angle = getAngle(width, height)
let rad = (Math.PI / 180) * (angle - newRotate)
let diffY = Math.sin(rad) * c
let diffX = Math.cos(rad) * c
item.elmX = item.lastElmX
item.elmY = item.lastElmY
item.elmW = item.lastElmW
item.elmH = item.lastElmH
let elmW = item.elmW as number
let elmH = item.elmH as number
let scale: number = elmW / elmH
let fixed: UTSJSONObject = { x: 'left', y: 'top' } as UTSJSONObject
// 根据操作角确定固定点和缩放方向
if (item.typetext == 'left-top') {
fixed = { x: 'right', y: 'bottom' } as UTSJSONObject
diffX = -diffX
elmW += diffX
elmH = elmW / scale
item.elmW = elmW
item.elmH = elmH
} else if (item.typetext == 'left-bottom') {
fixed = { x: 'right', y: 'top' } as UTSJSONObject
diffX = -diffX
elmW += diffX
elmH = elmW / scale
item.elmW = elmW
item.elmH = elmH
} else if (item.typetext == 'right-top') {
fixed = { x: 'left', y: 'bottom' } as UTSJSONObject
elmW += diffX
elmH = elmW / scale
item.elmW = elmW
item.elmH = elmH
} else if (item.typetext == 'right-bottom') {
fixed = { x: 'left', y: 'top' } as UTSJSONObject
elmW += diffX
elmH = elmW / scale
item.elmW = elmW
item.elmH = elmH
}
item.isDrag = true
// 尺寸限制
elmW = item.elmW as number
elmH = item.elmH as number
if (elmW < minWidth) {
elmW = minWidth
elmH = elmW / scale
item.elmW = elmW
item.elmH = elmH
item.isDrag = false
}
if (elmH < minHeight) {
elmH = minHeight
elmW = elmH * scale
item.elmW = elmW
item.elmH = elmH
item.isDrag = false
}
// 取消最大尺寸限制,允许无限放大
// if (elmW > maxWidth) {
// elmW = maxWidth
// elmH = elmW / scale
// item.elmW = elmW
// item.elmH = elmH
// }
// if (elmH > maxHeight) {
// elmH = maxHeight
// elmW = elmH * scale
// item.elmW = elmW
// item.elmH = elmH
// }
let getNewData = fixedTo(item, fixed, item.newRotate!)
item.elmX = getNewData['x'] as number
item.elmY = getNewData['y'] as number
item.left = item.elmX!
item.top = item.elmY!
item.width = item.elmW!
item.height = item.elmH!
// 磁性吸附(仅在触摸移动且无旋转时)
if (item.newRotate == 0) {
applyMagneticAdsorption(item, designWidth, designHeight, allLineListData)
}
// 更新中心位置
updateCenterPos(item)
}
/**
* 四边拉伸移动事件
*/
export function edgeMove(e: any, item: ItemElement, designWidth: number, designHeight: number, allLineListData: GuideLine[] = []) {
// 状态初始化
item.active = true
item.activeguide = false
item.activescale = false
item.activehorn = false
item.activeedge = true
item.isShowLineV = false
item.isShowLineH = false
// 获取移动位置
const touchEvent = e as UniTouchEvent
if (touchEvent.touches.length > 0) {
item.touchMoveX = touchEvent.touches[0].clientX
item.touchMoveY = touchEvent.touches[0].clientY
} else {
const mouseEvent = e as MouseEvent
item.touchMoveX = mouseEvent.clientX
item.touchMoveY = mouseEvent.clientY
}
let touchMoveX = item.touchMoveX as number
let touchMoveY = item.touchMoveY as number
let touchX = item.touchX as number
let touchY = item.touchY as number
let newRotate = item.newRotate as number
let width = touchMoveX - touchX
let height = touchMoveY - touchY
let c = Math.sqrt(Math.pow(width, 2) + Math.pow(height, 2))
let angle = getAngle(width, height)
let rad = (Math.PI / 180) * (angle - newRotate)
let diffY = Math.sin(rad) * c
let diffX = Math.cos(rad) * c
item.elmX = item.lastElmX
item.elmY = item.lastElmY
item.elmW = item.lastElmW
item.elmH = item.lastElmH
let elmW = item.elmW as number
let elmH = item.elmH as number
let fixed: UTSJSONObject = { x: 'left', y: 'top' } as UTSJSONObject
// 根据拉伸方向确定固定点
if (item.typetext == 'left') {
fixed = { x: 'right', y: 'top' } as UTSJSONObject
elmW += diffX * (-1)
} else if (item.typetext == 'top') {
fixed = { x: 'left', y: 'bottom' } as UTSJSONObject
elmH += diffY * (-1)
} else if (item.typetext == 'right') {
fixed = { x: 'left', y: 'top' } as UTSJSONObject
elmW += diffX * (1)
} else if (item.typetext == 'bottom') {
fixed = { x: 'left', y: 'top' } as UTSJSONObject
elmH += diffY * (1)
}
// 最小尺寸限制
if (elmW < 20) elmW = 20
if (elmH < 20) elmH = 20
item.elmW = elmW
item.elmH = elmH
let getNewData = fixedTo(item, fixed, item.newRotate!)
item.elmX = getNewData['x'] as number
item.elmY = getNewData['y'] as number
item.left = item.elmX!
item.top = item.elmY!
item.width = item.elmW!
item.height = item.elmH!
// 磁性吸附
if (item.newRotate == 0) {
applyEdgeMagneticAdsorption(item, designWidth, designHeight, allLineListData)
}
// 更新中心位置
updateCenterPos(item)
}
/**
* 拖拽移动事件
*/
export function dragMove(e: any, item: ItemElement, designWidth: number, designHeight: number, allLineListData: GuideLine[] = []) {
item.isShowLineV = false
item.isShowLineH = false
let mouseX: number
let mouseY: number
const touchEvent = e as UniTouchEvent
if (touchEvent.touches.length > 0) {
mouseX = touchEvent.touches[0].clientX
mouseY = touchEvent.touches[0].clientY
} else {
const mouseEvent = e as MouseEvent
mouseX = mouseEvent.clientX
mouseY = mouseEvent.clientY
}
item.elmX = item.lastElmX
item.elmY = item.lastElmY
item.elmW = item.lastElmW
item.elmH = item.lastElmH
let diffX = mouseX - (item.startX as number)
let diffY = mouseY - (item.startY as number)
let elmX = item.elmX as number
let elmY = item.elmY as number
elmX += diffX
elmY += diffY
item.elmX = elmX
item.elmY = elmY
item.left = elmX
item.top = elmY
// 磁性吸附
if (item.newRotate == 0) {
applyDragMagneticAdsorption(item, designWidth, designHeight, allLineListData)
}
item.left = item.elmX!
item.top = item.elmY!
// 更新中心位置
updateCenterPos(item)
}
/**
* 旋转移动事件
*/
export function rotateMove(e: any, item: ItemElement) {
let mouseX: number
let mouseY: number
const touchEvent = e as UniTouchEvent
if (touchEvent.touches.length > 0) {
mouseX = touchEvent.touches[0].clientX
mouseY = touchEvent.touches[0].clientY
} else {
const mouseEvent = e as MouseEvent
mouseX = mouseEvent.clientX
mouseY = mouseEvent.clientY
}
// 计算图片中心点(考虑缩放)
let centerX = item.left + (item.width * item.scale) / 2
let centerY = item.top + (item.height * item.scale) / 2
// 如果有缓存的中心点,使用缓存的
if (item.centerPos != null) {
centerX = item.centerPos['x'] as number
centerY = item.centerPos['y'] as number
}
let touchX = item.touchX as number
let touchY = item.touchY as number
// 计算起始角度(触摸点相对于中心点)
let startAngle = Math.atan2(touchY - centerY, touchX - centerX) * 180 / Math.PI
// 计算当前角度(当前触摸点相对于中心点)
let currentAngle = Math.atan2(mouseY - centerY, mouseX - centerX) * 180 / Math.PI
// 计算角度差
let deltaAngle = currentAngle - startAngle
// 更新旋转角度(基于初始角度)
let initialAngle = item.anglePre != null ? item.anglePre : 0
let rawAngle = initialAngle + deltaAngle
// 标准化角度到 -180 到 180
while (rawAngle > 180) rawAngle -= 360
while (rawAngle < -180) rawAngle += 360
// 应用角度吸附效果
item.angle = applyAngleSnap(rawAngle)
}
/**
* 角度吸附函数
* 当角度接近关键角度(0°、45°、90°、135°、180°、-45°、-90°、-135°)时自动吸附
*/
export function applyAngleSnap(angle: number): number {
const snapTolerance = 5 // 吸附容差:5度
const snapAngles = [0, 45, 90, 135, 180, -45, -90, -135] // 关键吸附角度
// 检查是否接近任何关键角度
for (let i = 0; i < snapAngles.length; i++) {
let snapAngle = snapAngles[i]
let diff = Math.abs(angle - snapAngle)
// 处理 180° 和 -180° 的特殊情况
if (snapAngle == 180 || snapAngle == -180) {
let diff180 = Math.abs(Math.abs(angle) - 180)
if (diff180 <= snapTolerance) {
return snapAngle
}
} else if (diff <= snapTolerance) {
return snapAngle
}
}
// 如果不在吸附范围内,返回原始角度
return angle
}
/**
* 更新中心位置
*/
export function updateCenterPos(item: ItemElement) {
item.centerPos = {
x: item.left + item.width / 2,
y: item.top + item.height / 2
}
item.x = item.centerPos!['x'] as number
item.y = item.centerPos!['y'] as number
}
/**
* 初始化位置
*/
export function initPos(item: ItemElement) {
if (item == null) return
item.centerPos = {
x: item.left + item.width / 2,
y: item.top + item.height / 2
}
item.x = item.centerPos!['x'] as number
item.y = item.centerPos!['y'] as number
}
/**
* 磁性吸附 - 四角缩放时
*/
export function applyMagneticAdsorption(item: ItemElement, designWidth: number, designHeight: number, allLineListData: GuideLine[]) {
const tolerance = 4 // 吸附容差
let distance = 0
if (item.typetext == 'left-top') {
let chosenGuides = getIshasDrawData(item, allLineListData)
let leftGuide = chosenGuides['left']! as UTSJSONObject
let leftDist = leftGuide['dist'] as number
if (leftDist <= tolerance && leftGuide['guide'] != null) {
item.isShowLineV = true
let leftGuideObj = leftGuide['guide']! as GuideLine
let leftOffset = leftGuide['offset'] as number
item.selectLineLeft = leftGuideObj.left
distance = item.left - (leftGuideObj.left - leftOffset)
let elmW = item.elmW as number
let elmH = item.elmH as number
let elmX = item.elmX as number
let elmY = item.elmY as number
let scale = elmW / elmH
elmX -= distance
elmY -= distance * scale
elmW += distance
elmH += distance * scale
item.elmX = elmX
item.elmY = elmY
item.elmW = elmW
item.elmH = elmH
} else {
let topGuide = chosenGuides['top']! as UTSJSONObject
let topDist = topGuide['dist'] as number
if (topDist <= tolerance && topGuide['guide'] != null) {
item.isShowLineH = true
let topGuideObj = topGuide['guide']! as GuideLine
let topOffset = topGuide['offset'] as number
item.selectLineTop = topGuideObj.top
distance = item.top - (topGuideObj.top - topOffset)
let elmW = item.elmW as number
let elmH = item.elmH as number
let elmX = item.elmX as number
let elmY = item.elmY as number
let scale = elmW / elmH
elmX -= distance / scale
elmY -= distance
elmW += distance / scale
elmH += distance
item.elmX = elmX
item.elmY = elmY
item.elmW = elmW
item.elmH = elmH
}
}
// 边界吸附
if (item.left > -tolerance && item.left < tolerance) {
distance = item.left
let elmW = item.elmW as number
let elmH = item.elmH as number
let elmX = item.elmX as number
let elmY = item.elmY as number
let scale = elmW / elmH
elmX -= distance
elmY -= distance * scale
elmW += distance
elmH += distance * scale
item.elmX = elmX
item.elmY = elmY
item.elmW = elmW
item.elmH = elmH
item.isShowLineV = true
item.selectLineLeft = 0
}
item.left = item.elmX as number
item.top = item.elmY as number
item.width = item.elmW as number
item.height = item.elmH as number
}
// 其他角的吸附逻辑类似...
}
/**
* 磁性吸附 - 边拉伸时
*/
export function applyEdgeMagneticAdsorption(item: ItemElement, designWidth: number, designHeight: number, allLineListData: GuideLine[]) {
const tolerance = 4
let distance = 0
if (item.typetext == 'right') {
let right = designWidth - (item.left + item.width)
let chosenGuides = getIshasDrawData(item, allLineListData)
let leftGuide = chosenGuides['left']! as UTSJSONObject
let leftDist = leftGuide['dist'] as number
if (leftDist <= tolerance && leftGuide['guide'] != null) {
item.isShowLineV = true
let leftGuideObj = leftGuide['guide']! as GuideLine
let leftOffset = leftGuide['offset'] as number
item.selectLineLeft = leftGuideObj.left
distance = (leftGuideObj.left - leftOffset) - item.left
let elmW = item.elmW as number
elmW += distance
item.elmW = elmW
} else if (right > -tolerance && right < tolerance) {
distance = right
let elmW = item.elmW as number
elmW += distance
item.elmW = elmW
item.isShowLineV = true
item.selectLineLeft = designWidth - 0.5
}
item.width = item.elmW as number
} else if (item.typetext == 'left') {
let chosenGuides = getIshasDrawData(item, allLineListData)
let leftGuide = chosenGuides['left']! as UTSJSONObject
let leftDist = leftGuide['dist'] as number
if (leftDist <= tolerance && leftGuide['guide'] != null) {
item.isShowLineV = true
let leftGuideObj = leftGuide['guide']! as GuideLine
let leftOffset = leftGuide['offset'] as number
item.selectLineLeft = leftGuideObj.left
distance = item.left - (leftGuideObj.left - leftOffset)
let elmX = item.elmX as number
let elmW = item.elmW as number
elmX -= distance
elmW += distance
item.elmX = elmX
item.elmW = elmW
} else if (item.left > -tolerance && item.left < tolerance) {
distance = item.left
let elmX = item.elmX as number
let elmW = item.elmW as number
elmX -= distance
elmW += distance
item.elmX = elmX
item.elmW = elmW
item.isShowLineV = true
item.selectLineLeft = 0
}
item.left = item.elmX as number
item.width = item.elmW as number
} else if (item.typetext == 'top') {
let chosenGuides = getIshasDrawData(item, allLineListData)
let topGuide = chosenGuides['top']! as UTSJSONObject
let topDist = topGuide['dist'] as number
if (topDist <= tolerance && topGuide['guide'] != null) {
item.isShowLineH = true
let topGuideObj = topGuide['guide']! as GuideLine
let topOffset = topGuide['offset'] as number
item.selectLineTop = topGuideObj.top
distance = item.top - (topGuideObj.top - topOffset)
let elmY = item.elmY as number
let elmH = item.elmH as number
elmY -= distance
elmH += distance
item.elmY = elmY
item.elmH = elmH
} else if (item.top > -tolerance && item.top < tolerance) {
distance = item.top
let elmY = item.elmY as number
let elmH = item.elmH as number
elmY -= distance
elmH += distance
item.elmY = elmY
item.elmH = elmH
item.isShowLineH = true
item.selectLineTop = 0
}
item.top = item.elmY as number
item.height = item.elmH as number
} else if (item.typetext == 'bottom') {
let bottom = designHeight - (item.top + item.height)
let chosenGuides = getIshasDrawData(item, allLineListData)
let topGuide = chosenGuides['top']! as UTSJSONObject
let topDist = topGuide['dist'] as number
if (topDist <= tolerance && topGuide['guide'] != null) {
item.isShowLineH = true
let topGuideObj = topGuide['guide']! as GuideLine
let topOffset = topGuide['offset'] as number
item.selectLineTop = topGuideObj.top
distance = (topGuideObj.top - topOffset) - item.top
let elmH = item.elmH as number
elmH += distance
item.elmH = elmH
} else if (bottom > -tolerance && bottom < tolerance) {
distance = bottom
let elmH = item.elmH as number
elmH += distance
item.elmH = elmH
item.isShowLineH = true
item.selectLineTop = designHeight - 0.6
}
item.height = item.elmH as number
}
}
/**
* 磁性吸附 - 拖拽时
*/
export function applyDragMagneticAdsorption(item: ItemElement, designWidth: number, designHeight: number, allLineListData: GuideLine[]) {
const tolerance = 5
let chosenGuides = getIshasDrawData(item, allLineListData)
let leftGuide = chosenGuides['left']! as UTSJSONObject
let leftDist = leftGuide['dist'] as number
// 垂直辅助线吸附
if (leftDist <= tolerance && leftGuide['guide'] != null) {
let leftGuideObj = leftGuide['guide']! as GuideLine
let leftOffset = leftGuide['offset'] as number
item.elmX = leftGuideObj.left - leftOffset
item.isShowLineV = true
item.selectLineLeft = leftGuideObj.left
} else {
// 中心线吸附
if (item.left > (designWidth / 2 - tolerance) && item.left < (designWidth / 2 + tolerance)) {
item.elmX = designWidth / 2
} else if (item.left > (designWidth / 2 - item.width - tolerance) && item.left < (designWidth / 2 - item.width + tolerance)) {
item.elmX = designWidth / 2 - item.width
} else if (item.left > -tolerance && item.left < tolerance) {
item.elmX = 0
} else if (item.left > designWidth - item.width - tolerance && item.left < designWidth - item.width + tolerance) {
item.elmX = designWidth - item.width
} else if (item.left > ((designWidth - item.width) / 2 - tolerance) && item.left < ((designWidth - item.width) / 2 + tolerance)) {
item.elmX = (designWidth - item.width) / 2
}
}
// 水平辅助线吸附
let topGuide = chosenGuides['top']! as UTSJSONObject
let topDist = topGuide['dist'] as number
if (topDist <= tolerance && topGuide['guide'] != null) {
let topGuideObj = topGuide['guide']! as GuideLine
let topOffset = topGuide['offset'] as number
item.elmY = topGuideObj.top - topOffset
item.isShowLineH = true
item.selectLineTop = topGuideObj.top
} else {
// 中心线吸附
let centerY = designHeight / 2
if (item.top > (centerY - item.height - tolerance) && item.top < (centerY - item.height + tolerance)) {
item.elmY = centerY - item.height
} else if (item.top > -tolerance && item.top < tolerance) {
item.elmY = 0
} else if (item.top > designHeight - item.height - tolerance && item.top < designHeight - item.height + tolerance) {
item.elmY = designHeight - item.height
} else if (item.top > (centerY - (item.height / 2) - tolerance) && item.top < (centerY - (item.height / 2) + tolerance)) {
item.elmY = centerY - (item.height / 2)
}
}
}
/**
* 检测是否需要显示辅助线
*/
export function getIshasDrawData(item: ItemElement, allLineListData: GuideLine[]): UTSJSONObject {
let pos = {
left: item.left,
top: item.top
}
let chosenGuides: UTSJSONObject = {
top: { dist: 5 } as UTSJSONObject,
left: { dist: 5 } as UTSJSONObject
} as UTSJSONObject
let selectLineListData = addallLineListDataXAndY(item)
if (allLineListData.length > 0 && selectLineListData.length > 0) {
allLineListData.forEach((guide: GuideLine) => {
selectLineListData.forEach((elemGuide: UTSJSONObject) => {
let elemType = elemGuide['type'] as string
if (guide.type == elemType) {
let prop = guide.type == 'h' ? 'top' : 'left'
let elemPropVal = elemGuide[prop] as number
let guidePropVal = guide[prop] as number
let posPropVal = pos[prop] as number
let d = Math.abs(elemPropVal - guidePropVal)
let chosenProp = chosenGuides[prop] as UTSJSONObject
let chosenDist = chosenProp['dist'] as number
if (d < chosenDist) {
chosenProp['dist'] = d
chosenProp['offset'] = elemPropVal - posPropVal
chosenProp['guide'] = guide
}
}
})
})
}
return chosenGuides
}
/**
* 添加元素的所有参考线坐标
*/
export function addallLineListDataXAndY(item: ItemElement): UTSJSONObject[] {
if (item.angle != 0) {
// 旋转情况下的参考线计算
let center = {
x: item.left + item.width / 2,
y: item.top + item.height / 2
}
let topLeft = { x: item.left, y: item.top }
let topRight = { x: item.left + item.width, y: item.top }
let bottomLeft = { x: item.left, y: item.top + item.height }
let bottomRight = { x: item.left + item.width, y: item.top + item.height }
let point1 = calNewPos(topLeft, center, item.angle)
let point2 = calNewPos(topRight, center, item.angle)
let point3 = calNewPos(bottomLeft, center, item.angle)
let point4 = calNewPos(bottomRight, center, item.angle)
return [
{ type: 'h', left: point1.x, top: point1.y },
{ type: 'h', left: point2.x, top: point2.y },
{ type: 'h', left: point3.x, top: point3.y },
{ type: 'h', left: point4.x, top: point4.y },
{ type: 'v', left: point1.x, top: point1.y },
{ type: 'v', left: point2.x, top: point2.y },
{ type: 'v', left: point3.x, top: point3.y },
{ type: 'v', left: point4.x, top: point4.y }
]
} else {
return [
{ type: 'h', left: item.left, top: item.top },
{ type: 'h', left: item.left, top: item.top + item.height },
{ type: 'v', left: item.left, top: item.top },
{ type: 'v', left: item.left + item.width, top: item.top },
{ type: 'h', left: item.left, top: item.top + item.height / 2 },
{ type: 'v', left: item.left + item.width / 2, top: item.top }
]
}
}
/**
* 通过旋转角度计算新坐标
*/
export function calNewPos(origin: UTSJSONObject, center: UTSJSONObject, angle: number): UTSJSONObject {
let radian = Math.PI / 180 * angle
let originX = origin['x'] as number
let originY = origin['y'] as number
let centerX = center['x'] as number
let centerY = center['y'] as number
let newX = (originX - centerX) * Math.cos(radian) - (originY - centerY) * Math.sin(radian) + centerX
let newY = (originY - centerY) * Math.cos(radian) + (originX - centerX) * Math.sin(radian) + centerY
return {
x: parseFloat(newX.toFixed(4)),
y: parseFloat(newY.toFixed(4))
}
}
.sheet-root {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999;
}
.sheet-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.45);
opacity: 0;
transition: opacity 0.26s ease;
}
.sheet-mask--show {
opacity: 1;
}
.sheet-mask--hide {
opacity: 0;
}
.sheet-panel-wrapper {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
}
.sheet-panel {
width: 100%;
max-height: 600rpx;
background-color: #FFFFFF;
border-top-left-radius: 28rpx;
border-top-right-radius: 28rpx;
padding: 24rpx 36rpx 36rpx;
box-shadow: 0px -8rpx 32rpx rgba(0, 0, 0, 0.08);
transform: translateY(100%);
transition: transform 0.26s ease;
display: flex;
flex-direction: column;
}
.sheet-panel--show {
transform: translateY(0%);
}
.sheet-handle {
width: 110rpx;
height: 10rpx;
border-radius: 5rpx;
background-color: rgba(0, 0, 0, 0.12);
align-self: center;
margin-bottom: 24rpx;
}
.sheet-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
}
.sheet-header-left {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.sheet-title {
text-align: center;
font-size: 30rpx;
color: #1F1F1F;
font-weight: bold;
}
.sheet-header-right {
width: 48rpx;
height: 48rpx;
}
.sheet-close-area {
width: 48rpx;
height: 48rpx;
border-radius: 24rpx;
background-color: rgba(0, 0, 0, 0.05);
display: flex;
justify-content: center;
align-items: center;
}
.sheet-close-icon {
width: 28rpx;
height: 28rpx;
}
.sheet-content {
flex: 1;
overflow: hidden;
}
.product-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
padding: 20rpx 0;
}
.product-item {
width: 150rpx;
display: flex;
flex-direction: column;
align-items: center;
margin-right: 15rpx;
margin-bottom: 30rpx;
padding: 20rpx;
border-radius: 7rpx;
}
.product-image {
width: 120rpx;
height: 120rpx;
margin-bottom: 16rpx;
}
.product-name {
font-size: 20rpx;
color: #333333;
text-align: center;
line-height: 1.4;
}
/* 加载状态样式 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
flex: 1;
}
.loading-gif {
width: 120rpx;
height: 120rpx;
margin-bottom: 15rpx;
}
.loading-text {
font-size: 28rpx;
color: #666666;
margin-top: 20rpx;
}
/* 无数据状态样式 */
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
flex: 1;
}
.empty-image {
width: 200rpx;
height: 200rpx;
margin-bottom: 20rpx;
}
.empty-text {
font-size: 28rpx;
color: #999999;
}
<template>
<view v-if="Bindvisible" class="popup-overlay" :class="{ 'popup-overlay-enter': showAnimation }">
<view class="popup-fixed" :class="{ 'popup-scale-enter': showAnimation }">
<view class="popup-container-bg">
<!-- <image src="/static/UploadQrcode.png" class="popup-bg-image" mode="aspectFill"></image> -->
<view class="popup-container">
<text class="popup-title">
绑定设备
</text>
<view class="popup-input-container">
<input class="popup-input" placeholder-style="placeholders" type="number" placeholder="请输入设备应用码"
v-model="machine_code" />
</view>
<view class="popup-btn">
<button class="popup-btn-btn" @click="handleBind">确定</button>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { binds, type ResponseData } from '../../utils/reques.uts'
// #ifdef APP
import { getDevicesInfo } from "@/uni_modules/zws-deviceInfo"
// #endif
type PopupUploadProps = {
Bindvisible : boolean
closeOnOverlay : boolean
}
type PopupUploadEmits = {
(e : 'close') : void
(e : 'success') : void
}
const props = withDefaults(defineProps<PopupUploadProps>(), {
Bindvisible: false,
closeOnOverlay: false
})
const emit = defineEmits<PopupUploadEmits>()
const machine_code = ref('')
const device_id = ref('')
const showAnimation = ref(false)
onMounted(() => {
// #ifdef APP
device_id.value = getDevicesInfo().ANDROID_ID
// #endif
})
// 监听弹窗显示状态,控制动画
watch(() : boolean => props.Bindvisible, (newVal : boolean) : void => {
if (newVal) {
// 弹窗打开时,延迟一帧触发动画
nextTick(() => {
showAnimation.value = true
})
} else {
// 弹窗关闭时,重置动画状态
showAnimation.value = false
}
})
const handleBind = async () : Promise<void> => {
const params = {
machine_code: machine_code.value,
device_id: device_id.value,
} as object
try {
const res = await binds(params) as ResponseData
if (res.code == 0) {
uni.setStorage({ key: 'machine_code', data: machine_code.value })
uni.setStorage({ key: 'device_id', data: device_id.value })
uni.showToast({ title: '绑定成功', icon: 'none' })
emit('success')
emit('close')
} else {
uni.showToast({ title: res.message, icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '绑定失败', icon: 'none' })
console.log('绑定失败:', e)
}
}
</script>
<style>
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0);
z-index: 99;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.3s ease;
}
.popup-overlay-enter {
background-color: rgba(0, 0, 0, 0.5);
}
.popup-fixed {
margin-top: 50rpx;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
transform: scale(0.7);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.popup-scale-enter {
transform: scale(1);
}
.popup-container {
height: 547.92rpx;
position: relative;
z-index: 2;
align-items: center;
}
.popup-container-bg {
display: flex;
justify-content: center;
position: relative;
width: 660rpx;
height: 516rpx;
background: #FFFFFF;
border-radius: 20px;
}
.popup-title {
margin-top: 57rpx;
font-weight: bold;
font-size: 40rpx;
color: #21061A;
align-items: center;
}
.popup-bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.icon-hand,
.icon-lightning {
font-size: 18rpx;
}
.popup-input-container {
width: 100%;
align-items: center;
margin-top: 75rpx;
}
.popup-input {
width: 460rpx;
height: 88rpx;
background: #FFFFFF;
border: 2rpx solid #B2B2B2;
border-radius: 10rpx;
font-weight: 400;
font-size: 25rpx;
color: #333333;
text-align: center;
}
.placeholders {
font-family: PingFang SC;
font-weight: 400;
font-size: 36rpx;
color: #333333;
}
.popup-btn {
margin-top: 102rpx;
width: 100%;
height: 100%;
align-items: center;
}
.popup-btn-btn {
width: 50%;
height: 76rpx;
background: #FD3DA0;
border-radius: 10rpx;
font-family: PingFang SC;
font-weight: 400;
font-size: 36rpx;
color: #FFFFFF;
line-height: 76rpx;
border: none;
}
</style>
\ No newline at end of file
<template>
<view v-if="visible" class="popup-overlay" :class="{ 'popup-overlay-enter': showAnimation }">
<view class="popup-overlay__content" :class="{ 'popup-scale-enter': showAnimation }"
@click="handleContentClick">
<view class="popup-container">
<!-- 背景图片 -->
<image src="/static/popup.png" class="popup-bg-image" mode="aspectFit"></image>
<!-- 弹窗内容 -->
<view class="popup-content">
<view class="popup-title">
<text class="title-text">请选择</text>
<text class="title-text">上传的方式</text>
</view>
<view class="btn-group">
<button class="Upload-btn" hover-class="hover_btn" @click="handleUpload">手机上传</button>
<button class="Qrcode-btn" hover-class="hover_btn" @click="handleTakePhoto">设备拍照</button>
</view>
</view>
</view>
<!-- 关闭按钮 -->
<view class="close-button" @click="handleClose">
<image src="/static/icon_close.png" class="close-icon"></image>
</view>
</view>
</view>
</template>
<script setup lang="uts">
// 定义组件属性接口
interface PopupCheckProps {
visible : boolean
closeOnOverlay ?: boolean
}
// 定义组件事件接口
interface Emits {
(e : 'close') : void
(e : 'upload') : void
(e : 'takePhoto') : void
}
// 定义属性默认值
const props = withDefaults(defineProps<PopupCheckProps>(), {
visible: false
})
const emit = defineEmits<Emits>()
const showAnimation = ref(false)
// 组件挂载时的处理
onMounted(() => {
// 组件挂载完成
})
// 组件卸载时的处理
onUnmounted(() => {
// 组件卸载完成
})
// 监听弹窗显示状态,控制动画
watch(() : boolean => props.visible, (newVal : boolean) : void => {
if (newVal) {
// 弹窗打开时,延迟一帧触发动画
nextTick(() => {
showAnimation.value = true
})
} else {
// 弹窗关闭时,重置动画状态
showAnimation.value = false
}
})
// 处理遮罩层点击
const handleOverlayClick = () => {
if (props.closeOnOverlay == true) {
emit('close')
}
}
// 处理内容区域点击
const handleContentClick = () => {
// 阻止事件冒泡,防止关闭弹窗
}
// 处理关闭按钮点击
const handleClose = () => {
emit('close')
}
// 处理上传按钮点击
const handleUpload = () => {
emit('upload')
}
// 处理拍照按钮点击
const handleTakePhoto = () => {
emit('takePhoto')
}
</script>
<style>
/* 弹窗遮罩层 */
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.3s ease;
}
.popup-overlay-enter {
background-color: rgba(0, 0, 0, 0.5);
}
/* 弹窗内容容器 */
.popup-overlay__content {
margin-top: 50rpx;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
transform: scale(0.7);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.popup-scale-enter {
transform: scale(1);
}
/* 弹窗容器 */
.popup-container {
width: 498.61rpx;
height: 547.92rpx;
position: relative;
}
/* 弹窗背景图片 */
.popup-bg-image {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
/* 弹窗内容 */
.popup-content {
position: relative;
z-index: 2;
width: 100%;
height: 100%;
}
/* 弹窗标题 */
.popup-title {
margin: 132.17rpx 0 55.56rpx 55.56rpx;
}
/* 弹窗标题文字样式:文字样式必须设置在 <text> 上 */
/* 按钮组容器 */
.btn-group {
display: flex;
justify-content: flex-start;
margin: 0rpx 0 0 55rpx;
flex-direction: column;
}
/* 上传按钮 */
.Upload-btn {
width: 194rpx;
height: 67rpx;
background: linear-gradient(to bottom, #FD3C9F, #FCA7D3);
font-weight: bold;
font-size: 25rpx;
color: #000000;
line-height: 67rpx;
border-radius: 20rpx;
border: none;
margin-bottom: 16rpx;
}
/* 拍照按钮 */
.Qrcode-btn {
width: 194rpx;
height: 67rpx;
background: #fff5fa;
font-weight: bold;
font-size: 25rpx;
color: #000000;
line-height: 67rpx;
border-radius: 20rpx;
border: none;
}
/* 关闭按钮 */
.close-button {
margin-top: 21rpx;
width: 48rpx;
height: 48rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
border-radius: 25rpx;
display: flex;
justify-content: center;
align-items: center;
}
/* 关闭图标 */
.close-icon {
width: 48rpx;
height: 48rpx;
}
/* 弹窗标题文字样式 */
.title-text {
font-size: 36rpx;
font-weight: bold;
color: #21061A;
line-height: 1.2em;
}
</style>
\ No newline at end of file
<template>
<view v-if="visible" class="popup-overlay" :class="{ 'popup-overlay-enter': showAnimation }">
<view class="popup-fixed" :class="{ 'popup-scale-enter': showAnimation }">
<view class="popup-container">
<view class="popup-header">
<text class="popup-title">设备信息</text>
<view class="popup-close" @click="handleClose">
<image src="/static/icon_close.png" class="close-icon" mode="aspectFit"></image>
</view>
</view>
<scroll-view class="popup-content" scroll-y="true">
<view class="info-section" v-if="deviceData">
<!-- 标识与编码 -->
<view class="info-group">
<!-- <text class="group-title">标识与编码</text> -->
<view class="info-item" v-if="deviceData?.machine_id != null">
<text class="info-label">设备ID:</text>
<text class="info-value">{{ deviceData?.machine_id }}</text>
</view>
<view class="info-item" v-if="deviceData?.machine_code">
<text class="info-label">设备码:</text>
<text class="info-value">{{ deviceData?.machine_code }}</text>
</view>
<view class="info-item" v-if="deviceData?.code != null">
<text class="info-label">应用编码:</text>
<text class="info-value">{{ deviceData?.code }}</text>
</view>
<view class="info-item">
<text class="info-label">应用版本:</text>
<text class="info-value">{{ appVersionText }}</text>
</view>
<view class="info-item" v-if="deviceData?.app_value?.label != null">
<text class="info-label">应用类型:</text>
<text class="info-value">{{ deviceData?.app_value?.label }}</text>
</view>
<view class="info-item" v-if="deviceData?.language_value?.label != null">
<text class="info-label">默认语言:</text>
<text class="info-value">{{ deviceData?.language_value?.label }}</text>
</view>
<!-- <text class="group-title">状态与绑定</text> -->
<view class="info-item" v-if="deviceData?.flag != null && deviceData?.flag?.value != null">
<text class="info-label">设备状态:</text>
<text class="info-value"
:class="getStatusClass(deviceData?.flag?.value)">{{ getOnlineStatusText(deviceData?.flag?.value) }}</text>
</view>
<view class="info-item"
v-if="deviceData?.bind_device != null && deviceData?.bind_device?.value != null">
<text class="info-label">绑定状态:</text>
<text class="info-value">{{ getBindStatusText(deviceData?.bind_device?.value) }}</text>
</view>
<view class="info-item" v-if="deviceData?.device_id != null">
<text class="info-label">唯一标识:</text>
<text class="info-value">{{ deviceData?.device_id }}</text>
</view>
<view class="info-item" v-if="deviceData?.currency_symbols">
<text class="info-label">货币符号:</text>
<text class="info-value">{{ deviceData?.currency_symbols }}</text>
</view>
<view class="info-item" v-if="deviceData?.create_time">
<text class="info-label">创建时间:</text>
<text class="info-value">{{ deviceData?.create_time }}</text>
</view>
<view class="info-item" v-if="deviceData?.update_time">
<text class="info-label">更新时间:</text>
<text class="info-value">{{ deviceData?.update_time }}</text>
</view>
<!-- 摄像头位置选择 -->
<view class="info-item">
<text class="info-label">摄像头前置开启:</text>
<switch :checked="cameraPositionFront" @change="handleCameraPositionChange" color="#FD3DA0" />
</view>
<!-- 镜像开关 -->
<view class="info-item">
<text class="info-label">摄像头镜像开启:</text>
<switch :checked="cameraMirrorEnabled" @change="handleMirrorChange" color="#FD3DA0" />
</view>
</view>
</view>
<!-- 无数据提示 -->
<view v-else class="no-data">
<text class="no-data-text">暂无设备信息</text>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
// 定义设备信息的类型结构
type ValueItem = {
label : string | null
value : string | number | null
}
type MachineInfo = {
partner_name : string | null
machine_code : string | null
}
type DeviceInfo = {
machine_id : string | null
machine_code : string | null
code : string | null
app_value : ValueItem | null
language_value : ValueItem | null
flag : ValueItem | null
bind_device : ValueItem | null
device_id : string | null
print_quota : number | string | null
machine : MachineInfo | null
currency_symbols : string | null
create_time : string | null
update_time : string | null
}
type DeviceInfoProps = {
visible : boolean
closeOnOverlay : boolean
deviceInfo : UTSJSONObject | null
}
type DeviceInfoEmits = {
(e : 'close') : void
}
const props = withDefaults(defineProps<DeviceInfoProps>(), {
visible: false,
closeOnOverlay: true,
deviceInfo: null
})
const emit = defineEmits<DeviceInfoEmits>()
const showAnimation = ref(false)
// 应用版本(仅在 APP 平台获取),其他平台显示为“未知”
type AppBaseInfo = {
appVersion : string
}
const appVersion = ref<string | null>(null)
const appVersionText = computed(() : string => {
if (appVersion.value != null) {
return appVersion.value as string
}
return '未知'
})
// 镜像开关状态
const cameraMirrorEnabled = ref<boolean>(false)
// 摄像头位置状态(true=前置,false=后置)
const cameraPositionFront = ref<boolean>(false)
onMounted(() : void => {
// #ifdef APP
const info = uni.getAppBaseInfo()
appVersion.value = info.appVersion
// #endif
// 从缓存读取镜像状态和摄像头位置
const storedMirror = uni.getStorageSync('cameraMirror')
const storedPosition = uni.getStorageSync('cameraPosition')
// 处理 storedMirror 可能是字符串或布尔值的情况
if (storedMirror != null) {
if (typeof storedMirror == 'boolean') {
cameraMirrorEnabled.value = storedMirror as boolean
} else if (typeof storedMirror == 'string') {
cameraMirrorEnabled.value = (storedMirror as string) === 'true'
} else {
cameraMirrorEnabled.value = false
}
} else {
cameraMirrorEnabled.value = false
}
cameraPositionFront.value = storedPosition != null ? (storedPosition as string) === 'front' : false
})
// 处理摄像头位置变化
const handleCameraPositionChange = (e : UniSwitchChangeEvent) => {
const checked = e.detail.value
cameraPositionFront.value = checked
const position = checked ? 'front' : 'back'
// 保存到全局状态
const app = getApp()
app.globalData.cameraPosition = position
// 保存到缓存
uni.setStorageSync('cameraPosition', position)
console.log('摄像头位置已更新:', checked ? '前置' : '后置')
}
// 处理镜像开关变化
const handleMirrorChange = (e : UniSwitchChangeEvent) => {
const checked = e.detail.value
cameraMirrorEnabled.value = checked
// 保存到全局状态
const app = getApp()
app.globalData.cameraMirror = checked
// 保存到缓存
uni.setStorageSync('cameraMirror', checked)
console.log('摄像头镜像状态已更新:', checked)
}
// 将 UTSJSONObject 转换为类型安全的 DeviceInfo
const deviceData = computed(() : DeviceInfo | null => {
if (props.deviceInfo == null) return null
const data = props.deviceInfo as UTSJSONObject
// 辅助函数:安全获取 ValueItem
const getValueItem = (key : string) : ValueItem | null => {
const item = data[key]
if (item == null) return null
const obj = item as UTSJSONObject
return {
label: obj.getString('label'),
value: obj['value'] as string | number | null
}
}
// 辅助函数:安全获取 MachineInfo
const getMachineInfo = () : MachineInfo | null => {
const machine = data['machine']
if (machine == null) return null
const obj = machine as UTSJSONObject
return {
partner_name: obj.getString('partner_name'),
machine_code: obj.getString('machine_code')
}
}
return {
machine_id: data.getString('machine_id'),
machine_code: data.getString('machine_code'),
code: data.getString('code'),
app_value: getValueItem('app_value'),
language_value: getValueItem('language_value'),
flag: getValueItem('flag'),
bind_device: getValueItem('bind_device'),
device_id: data.getString('device_id'),
print_quota: data['print_quota'] as number | string | null,
machine: getMachineInfo(),
currency_symbols: data.getString('currency_symbols'),
create_time: data.getString('create_time'),
update_time: data.getString('update_time')
}
})
// 监听弹窗显示状态,控制动画
watch(() : boolean => props.visible, (newVal : boolean) : void => {
if (newVal) {
// 每次打开弹窗时重新从缓存读取状态
const storedMirror = uni.getStorageSync('cameraMirror')
const storedPosition = uni.getStorageSync('cameraPosition')
// 处理 storedMirror 可能是字符串或布尔值的情况
if (storedMirror != null) {
if (typeof storedMirror == 'boolean') {
cameraMirrorEnabled.value = storedMirror as boolean
} else if (typeof storedMirror == 'string') {
cameraMirrorEnabled.value = (storedMirror as string) === 'true'
} else {
cameraMirrorEnabled.value = false
}
} else {
cameraMirrorEnabled.value = false
}
cameraPositionFront.value = storedPosition != null ? (storedPosition as string) === 'front' : false
console.log('弹窗打开 - 摄像头镜像:', cameraMirrorEnabled.value)
console.log('弹窗打开 - 摄像头位置:', cameraPositionFront.value ? '前置' : '后置')
nextTick(() => {
showAnimation.value = true
console.log('deviceInfo', deviceData.value)
})
} else {
showAnimation.value = false
}
}, { immediate: false })
// 处理遮罩层点击
const handleOverlayClick = () => {
if (props.closeOnOverlay) {
handleClose()
}
}
// 关闭弹窗
const handleClose = () => {
showAnimation.value = false
setTimeout(() => {
emit('close')
}, 200)
}
// 将 0/1 或 '0'/'1' 统一为字符串; 其他返回 null
const toBinaryString = (value : string | number | null) : string | null => {
if (value == null) return null
const t = typeof value
if (t == 'number') {
const numVal = value as number
if (numVal == 1) return '1'
if (numVal == 0) return '0'
return null
}
if (t == 'string') {
const strVal = value as string
if (strVal == '1' || strVal == '0') return strVal
return null
}
return null
}
// 获取"在线/离线/未知"文本
const getOnlineStatusText = (value : string | number | null) : string => {
const v = toBinaryString(value)
if (v == null) return '未知'
if (v == '1') return '在线'
if (v == '0') return '离线'
return '未知'
}
// 获取绑定状态文本(已绑定/未绑定/未知)
const getBindStatusText = (value : string | number | null) : string => {
const v = toBinaryString(value)
if (v == null) return '未知'
if (v == '1') return '已绑定'
if (v == '0') return '未绑定'
return '未知'
}
// 获取"是/否/未知"文本(用于是否直接打印、是否无限打印)
const getYesNoText = (value : string | number | null) : string => {
const v = toBinaryString(value)
if (v == null) return '未知'
if (v == '1') return '是'
if (v == '0') return '否'
return '未知'
}
// 获取状态样式类(在线/离线/未知)
const getStatusClass = (value : string | number | null) : string => {
const v = toBinaryString(value)
if (v == null) return 'status-unknown'
if (v == '1') return 'status-online'
if (v == '0') return 'status-offline'
return 'status-unknown'
}
</script>
<style>
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
transition: opacity 0.2s ease;
}
.popup-overlay-enter {
opacity: 1;
}
.popup-fixed {
width: 90%;
max-width: 600rpx;
height: 70%;
background: #FFFFFF;
border-radius: 20rpx;
transform: scale(0.8);
transition: transform 0.2s ease;
}
.popup-scale-enter {
transform: scale(1);
}
.popup-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.popup-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #F0F0F0;
}
.popup-title {
font-size: 36rpx;
font-weight: bold;
color: #333333;
}
.popup-close {
width: 40rpx;
height: 40rpx;
display: flex;
justify-content: center;
align-items: center;
background: #000000;
border-radius: 9999rpx;
}
.close-icon {
width: 30rpx;
height: 34rpx;
}
.popup-content {
flex: 1;
padding: 0 30rpx 30rpx 30rpx;
}
.info-section {
width: 100%;
}
.info-group {
margin-bottom: 40rpx;
}
.group-title {
font-size: 32rpx;
font-weight: bold;
color: #333333;
margin-bottom: 20rpx;
}
.info-item {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
padding: 20rpx 0;
border-bottom: 1rpx solid #F5F5F5;
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-size: 28rpx;
color: #333333;
flex-shrink: 0;
font-weight: bold;
;
width: 260rpx;
}
.info-value {
font-size: 28rpx;
color: #666666;
flex: 1;
text-align: right;
/* 长文本换行显示,避免被截断 */
white-space: normal;
}
.status-online {
color: #52C41A;
}
.status-offline {
color: #FF4D4F;
}
.status-unknown {
color: #999999;
}
.no-data {
display: flex;
justify-content: center;
align-items: center;
height: 200rpx;
}
.no-data-text {
font-size: 28rpx;
color: #999999;
}
</style>
\ No newline at end of file
<template>
<view v-if="visible" class="popup-overlay" :class="{ 'popup-overlay-enter': showAnimation }">
<view class="popup-fixed" :class="{ 'popup-scale-enter': showAnimation }">
<view class="popup-container-bg">
<image src="/static/printbg.png" class="popup-bg-image" mode="aspectFill"></image>
<view class="popup-container">
<!-- 加载状态 -->
<view class="loading-container" v-if="isLoading">
<image class="loading-gif" src="/static/Loading.gif" mode="aspectFit"></image>
<text class="loading-text">加载中...</text>
</view>
<!-- 无数据状态 -->
<view class="empty-container" v-else-if="!isLoading && items.length == 0">
<image class="empty-image" src="/static/error_black.png" mode="aspectFit"></image>
<text class="empty-text">暂无排队数据</text>
</view>
<!-- 数据内容 -->
<scroll-view class="list-scroll" v-else direction="vertical" @scrolltolower="handleScrollToLower"
lower-threshold="50">
<view v-for="(item,idx) in items" :key="item.id" class="queue-row"
@click="handleItemClick(item)">
<view class="thumb-wrap"
:class="{ 'thumb-active': (idx as Int) == 0 && item.status.value == 'printing' }">
<image class="thumb-image" :src="sanitize(item.works_image)" mode="aspectFill"></image>
<image v-if="item.goods_front_image" class="front-image-overlay"
:src="sanitize(item.goods_front_image)" mode="aspectFill"></image>
</view>
<view class="row-right">
<view class="info-column">
<text class="goods-name">{{ item.goods_name }}</text>
<text class="queue-text" :class="{ 'queue-text-printing': item.status.value == 'printing' }">{{ item.status.label }}</text>
</view>
</view>
</view>
<!-- 加载更多状态 -->
<view class="loadmore-container" v-if="isLoadingMore">
<image class="loadmore-gif" src="/static/Loading.gif" mode="aspectFit"></image>
<text class="loadmore-text">加载中...</text>
</view>
<!-- 没有更多数据 -->
<view class="nomore-container" v-if="!hasMore && items.length > 0">
<text class="nomore-text">没有更多数据了</text>
</view>
</scroll-view>
</view>
</view>
<view class="close-button" @click="handleClose">
<image src="/static/icon_close.png" class="close-icon"></image>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { queueList, type ResponseData } from '../../utils/reques.uts'
// 队列项与状态的严格类型
type QueueStatus = {
label : string
value : string
css_class : string | null
list_class : string | null
}
type QueueItem = {
id : number
order_id : number
works_id : number
works_image : string
status : QueueStatus
goods_name : string
goods_front_image : string
goods_design_color : string
}
interface PopupPrintListProps {
visible : boolean
closeOnOverlay : boolean
}
interface Emits {
(e : 'close') : void
}
const props = withDefaults(defineProps<PopupPrintListProps>(), {
visible: false,
closeOnOverlay: false
})
const emit = defineEmits<Emits>()
const showAnimation = ref(false)
const items = ref<Array<QueueItem>>([])
const isLoading = ref<boolean>(false)
const reqToken = ref<number>(0)
const page = ref<number>(1)
const limit = ref<number>(10)
const hasMore = ref<boolean>(true)
const isLoadingMore = ref<boolean>(false)
// 安全清理包裹的引号或反引号
const sanitize = (s : string) : string => {
let t : string = s.trim()
if (t.length == 0) return t
const startCode = t.charCodeAt(0)
if (startCode != null) {
const sc = startCode as number
if (sc == 96 || sc == 34 || sc == 39) {
t = t.substring(1)
}
}
if (t.length > 0) {
const endCode = t.charCodeAt(t.length - 1)
if (endCode != null) {
const ec = endCode as number
if (ec == 96 || ec == 34 || ec == 39) {
t = t.substring(0, t.length - 1)
}
}
}
return t.trim()
}
const fetchQueue = async (isLoadMore : boolean) : Promise<void> => {
if (isLoadMore) {
if (!hasMore.value || isLoadingMore.value) {
return
}
isLoadingMore.value = true
} else {
isLoading.value = true
}
const current : number = reqToken.value + 1
reqToken.value = current
const params = {
page: page.value,
limit: limit.value
} as UTSJSONObject
try {
const res = await queueList(params) as ResponseData
if (current != reqToken.value) {
return
}
if (res.code == 0) {
const arr = (res.data as Array<any>) ?? []
const out : Array<QueueItem> = []
for (let i = 0; i < arr.length; i++) {
const it = arr[i] as UTSJSONObject
const stObj = it.getAny('status') as UTSJSONObject | null
const st : QueueStatus = {
label: stObj != null ? (stObj.getString('label') ?? '') : '',
value: stObj != null ? (stObj.getString('value') ?? '') : '',
css_class: stObj != null ? (stObj.getString('css_class') ?? null) : null,
list_class: stObj != null ? (stObj.getString('list_class') ?? null) : null
}
const worksObj = it.getAny('works') as UTSJSONObject | null
const goodsObj = worksObj != null ? (worksObj.getAny('goods') as UTSJSONObject | null) : null
const qItem : QueueItem = {
id: it.getNumber('id') ?? 0,
order_id: it.getNumber('id') ?? 0,
works_id: it.getNumber('works_id') ?? 0,
works_image: sanitize(it.getString('works_image') ?? ''),
status: st,
goods_name: goodsObj != null ? (goodsObj.getString('name') ?? '') : '',
goods_front_image: goodsObj != null ? sanitize(goodsObj.getString('front_image') ?? '') : '',
goods_design_color: goodsObj != null ? (goodsObj.getString('design_color') ?? '') : ''
}
out.push(qItem)
}
if (isLoadMore) {
items.value = items.value.concat(out)
} else {
items.value = out
}
// 判断是否还有更多数据
if (arr.length < limit.value) {
hasMore.value = false
} else {
hasMore.value = true
}
}
} catch (e) {
if (current == reqToken.value) {
uni.showToast({ title: '获取队列失败', icon: 'none' })
}
} finally {
if (current == reqToken.value) {
isLoading.value = false
isLoadingMore.value = false
}
}
}
const resetState = () : void => {
showAnimation.value = false
items.value = []
isLoading.value = false
isLoadingMore.value = false
reqToken.value = reqToken.value + 1
page.value = 1
hasMore.value = true
}
watch(() : boolean => props.visible, (nv : boolean) : void => {
if (nv) {
isLoading.value = true
nextTick(() => { showAnimation.value = true })
fetchQueue(false)
} else {
resetState()
}
})
const handleOverlayClick = () : void => {
if (props.closeOnOverlay === true) {
emit('close')
}
}
const handleContentClick = () : void => {
// 阻止冒泡
}
const handleClose = () : void => {
resetState()
emit('close')
}
const handleItemClick = (item : QueueItem) : void => {
// 构造要传递的数据对象
const workData = {
works_id: item.works_id,
image: item.works_image,
order_id: item.order_id,
status: item.status.value,
status_label: item.status.label,
goods: {
name: item.goods_name,
front_image: item.goods_front_image,
design_color: item.goods_design_color
}
}
// 将数据转换为 JSON 字符串并编码
const workStr = encodeURIComponent(JSON.stringify(workData))
// 跳转到 printlist 页面
uni.navigateTo({
url: `/pages/printlist/index?work=${workStr}`
})
// 关闭弹窗
emit('close')
}
const handleScrollToLower = () : void => {
if (hasMore.value && !isLoadingMore.value) {
page.value = page.value + 1
fetchQueue(true)
}
}
onUnmounted(() => {
resetState()
})
</script>
<style>
/* 遮罩与弹窗过渡 */
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.3s ease;
}
.popup-overlay-enter {
background-color: rgba(0, 0, 0, 0.5);
}
.popup-fixed {
margin-top: 50rpx;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
transform: scale(0.7);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.popup-scale-enter {
transform: scale(1);
}
.popup-container {
width: 90%;
height: 537.92rpx;
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
}
.popup-container-bg {
display: flex;
justify-content: center;
position: relative;
width: 497.22rpx;
height: 633.33rpx;
align-items: center;
}
.popup-bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.list-scroll {
flex: 1;
width: 100%;
}
/* 加载状态样式 */
.loading-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.loading-gif {
width: 120rpx;
height: 120rpx;
margin-bottom: 15rpx;
}
.loading-text {
font-size: 28rpx;
color: #999999;
}
/* 无数据状态样式 */
.empty-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.empty-image {
width: 120rpx;
height: 120rpx;
margin-bottom: 15rpx;
}
.empty-text {
font-size: 28rpx;
color: #999999;
}
.queue-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
background-color: #FDE8F3;
border-radius: 18rpx;
padding: 20rpx;
margin-bottom: 20rpx;
}
.thumb-wrap {
width: 120rpx;
height: 120rpx;
border-radius: 12rpx;
border: 2rpx dashed rgba(0, 0, 0, 0);
display: flex;
justify-content: center;
align-items: center;
background-color: #FFFFFF;
position: relative;
}
.thumb-active {
border-color: #FF86C3;
}
.thumb-image {
width: 110rpx;
height: 110rpx;
border-radius: 8rpx;
}
.front-image-overlay {
position: absolute;
width: 110rpx;
height: 110rpx;
border-radius: 8rpx;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.row-right {
flex: 1;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
margin-left: 20rpx;
}
.info-column {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.goods-name {
font-size: 28rpx;
color: #333333;
}
.printing-btn {
width: 160rpx;
height: 64rpx;
background: linear-gradient(to bottom, #FD3C9F, #FCA7D3);
border-radius: 32rpx;
font-weight: bold;
font-size: 26rpx;
color: #FFFFFF;
line-height: 64rpx;
border: none;
}
.queue-text {
font-size: 26rpx;
color: #FD3C9F;
margin-top: 8rpx;
}
.queue-text-printing {
color: #00C853;
}
/* 关闭按钮 */
.close-button {
margin-top: 21rpx;
width: 48rpx;
height: 48rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
border-radius: 25rpx;
display: flex;
justify-content: center;
align-items: center;
}
/* 关闭图标 */
.close-icon {
width: 48rpx;
height: 48rpx;
}
/* 加载更多状态样式 */
.loadmore-container {
width: 100%;
padding: 20rpx 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.loadmore-gif {
width: 40rpx;
height: 40rpx;
margin-right: 10rpx;
}
.loadmore-text {
font-size: 24rpx;
color: #999999;
}
/* 没有更多数据样式 */
.nomore-container {
width: 100%;
padding: 20rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.nomore-text {
font-size: 24rpx;
color: #CCCCCC;
}
</style>
\ No newline at end of file
<template>
<view v-if="visible" class="popup-overlay" :class="{ 'popup-overlay-enter': showAnimation }">
<view class="popup-fixed" :class="{ 'popup-scale-enter': showAnimation }">
<image src="/static/PopupPrintov.png" class="popup-bg-image" mode="aspectFill"></image>
<image src="/static/icon_printing.gif" class="icon_success" />
<text class="print_text">{{ printText }}</text>
</view>
</view>
</template>
<script setup lang="uts">
type PopupUploadProps = {
visible : boolean
closeOnOverlay : boolean
printText : string
}
type PopupUploadEmits = {
(e : 'close') : void
}
const props = withDefaults(defineProps<PopupUploadProps>(), {
visible: false,
closeOnOverlay: false,
printText: '打印中...'
})
const emit = defineEmits<PopupUploadEmits>()
const showAnimation = ref(false)
// 监听弹窗显示状态,控制动画
watch(() : boolean => props.visible, (newVal : boolean) : void => {
if (newVal) {
// 弹窗打开时,延迟一帧触发动画
nextTick(() => {
showAnimation.value = true
})
} else {
// 弹窗关闭时,重置动画状态
showAnimation.value = false
}
})
const handleOverlayClick = () => {
if (props.closeOnOverlay == true) {
emit('close')
}
}
const handleClose = () => {
emit('close')
}
</script>
<style>
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.3s ease;
}
.popup-overlay-enter {
background-color: rgba(0, 0, 0, 0.5);
}
.popup-fixed {
margin-top: 50rpx;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
width: 440.97rpx;
height: 417.36rpx;
transform: scale(0.7);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.popup-scale-enter {
transform: scale(1);
}
.popup-bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.icon_success {
width: 100rpx;
height: 100rpx;
margin-bottom: 33rpx;
}
.print_text {
font-weight: bold;
font-size: 28rpx;
color: #FD3DA0;
align-items: center;
;
}
</style>
\ No newline at end of file
<template>
<view v-if="visible" class="popup-overlay" :class="{ 'popup-overlay-enter': showAnimation }">
<view class="popup-fixed" :class="{ 'popup-scale-enter': showAnimation }">
<image src="/static/PopupPrintov.png" class="popup-bg-image" mode="aspectFill"></image>
<image src="/static/icon_success.png" class="icon_success" />
<text class="print_text">您的作品已经打印完成</text>
<text class="print_text">请及时取货</text>
</view>
</view>
</template>
<script setup lang="uts">
type PopupUploadProps = {
visible : boolean
closeOnOverlay : boolean
}
type PopupUploadEmits = {
(e : 'close') : void
}
const props = withDefaults(defineProps<PopupUploadProps>(), {
visible: false,
closeOnOverlay: false
})
const emit = defineEmits<PopupUploadEmits>()
const showAnimation = ref(false)
// 监听弹窗显示状态,控制动画
watch((): boolean => props.visible, (newVal: boolean): void => {
if (newVal) {
// 弹窗打开时,延迟一帧触发动画
nextTick(() => {
showAnimation.value = true
})
} else {
// 弹窗关闭时,重置动画状态
showAnimation.value = false
}
})
const handleOverlayClick = () => {
if (props.closeOnOverlay == true) {
emit('close')
}
}
const handleClose = () => {
emit('close')
}
</script>
<style>
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.3s ease;
}
.popup-overlay-enter {
background-color: rgba(0, 0, 0, 0.5);
}
.popup-fixed {
margin-top: 50rpx;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
width: 440.97rpx;
height: 417.36rpx;
transform: scale(0.7);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.popup-scale-enter {
transform: scale(1);
}
.popup-bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.icon_success {
width: 100rpx;
height: 100rpx;
margin-bottom: 33rpx;
}
.print_text {
font-weight: bold;
font-size: 28rpx;
color: #FD3DA0;
align-items: center;
;
}
</style>
\ No newline at end of file
<template>
<view v-if="visible" class="popup-overlay" :class="{ 'popup-overlay-enter': showAnimation }">
<view class="popup-fixed" :class="{ 'popup-scale-enter': showAnimation }">
<view class="popup-container-bg">
<image src="/static/UploadQrcode.png" class="popup-bg-image" mode="aspectFill"></image>
<view class="popup-container">
<text class="popup-title">
微信扫码上传图片
</text>
<!-- 倒计时 -->
<view class="timer-section">
<text class="timer-text">上传剩余时间 {{ countdownText }}</text>
</view>
<!-- 主题指示器 -->
<!-- <view class="theme-section">
<view class="theme-line"></view>
<text class="theme-text">哈利波特</text>
<view class="theme-line"></view>
</view> -->
<!-- 警告信息 -->
<view class="warning-section">
<text class="warning-text">扫码过程中请勿关闭该弹窗</text>
</view>
<!-- 二维码区域 -->
<view class="qr-section">
<view class="qr-code-container" v-if="qrcode">
<image :src="qrcode" class="qr-code-image" mode="aspectFit" />
</view>
<view class="qr-loading" v-else>
<text class="loading-text">二维码生成中...</text>
</view>
<text class="qr-instruction">手机扫码即可参与</text>
</view>
</view>
</view>
<!-- 关闭按钮 -->
<view class="close-button" @click="handleClose">
<image src="/static/icon_close.png" class="close-icon" />
</view>
<!-- 二次确认弹窗 -->
<PopupConfirm
:visible="showConfirm"
title="提示"
content="当前二维码只对本次打开有效,是否退出?"
confirmText="退出"
cancelText="继续扫码"
:closeOnOverlay="true"
@confirm="onConfirmExit"
@cancel="onCancelExit"
/>
</view>
</view>
</template>
<script setup lang="uts">
import { uploadQrcode, type ResponseData } from '../../utils/reques.uts'
type PopupUploadProps = {
visible : boolean
closeOnOverlay : boolean
}
const qrcode = ref('')
const getQrcode = async (fdParam : string) : Promise<void> => {
try {
const res = await uploadQrcode(fdParam) as ResponseData
if (res.code == 0 && res.data != null) {
const dataObj = res.data as UTSJSONObject
qrcode.value = dataObj.getString('base_64_image') ?? ''
} else {
console.log('获取二维码失败:', res.message)
}
} catch (error) {
console.log('请求二维码失败:', error)
}
}
const WS_URL = 'ws://www.colorpark.cn:9910/?client=ai'
const socketFd = ref('')
let socketTask : SocketTask | null = null
const closeSocket = () : void => {
try {
// 创建本地引用避免智能转换问题
const currentSocketTask = socketTask
if (currentSocketTask != null) {
currentSocketTask.close({
code: 1000,
reason: 'close',
success: (res : any) => {
console.log('WebSocket 关闭成功')
},
fail: (err : any) => {
console.log('WebSocket 关闭失败:', err)
}
})
socketTask = null
}
} catch (e) {
console.log('关闭 WebSocket 失败:', e)
}
}
type PopupUploadEmits = {
(e : 'close') : void
}
const props = withDefaults(defineProps<PopupUploadProps>(), {
visible: false,
closeOnOverlay: false
})
const emit = defineEmits<PopupUploadEmits>()
// 3分钟倒计时(显示用)
const countdownSeconds = ref(180)
const countdownText = computed(() : string => {
const m = Math.floor(countdownSeconds.value / 60)
const s = countdownSeconds.value % 60
const mm = m < 10 ? '0' + m : '' + m
const ss = s < 10 ? '0' + s : '' + s
return mm + '分' + ss + '秒'
})
// 定时器句柄
let countdownInterval : number = -1
let autoCloseTimer : number = -1
const showAnimation = ref(false)
// 二次确认弹窗显示状态
const showConfirm = ref(false)
const clearTimers = () : void => {
if (countdownInterval != -1) {
clearInterval(countdownInterval)
countdownInterval = -1
}
if (autoCloseTimer != -1) {
clearTimeout(autoCloseTimer)
autoCloseTimer = -1
}
}
const startCountdown = () : void => {
clearTimers()
countdownSeconds.value = 180
countdownInterval = setInterval(() => {
if (countdownSeconds.value > 0) {
countdownSeconds.value = countdownSeconds.value - 1
} else {
clearTimers()
emit('close')
}
}, 1000)
}
const handleOverlayClick = () => {
if (props.closeOnOverlay == true) {
clearTimers()
closeSocket()
emit('close')
}
}
// 实际关闭逻辑(用于确认通过后或代码内部直接调用)
const doClose = () : void => {
clearTimers()
closeSocket()
emit('close')
}
// 关闭按钮点击,弹出二次确认
const handleClose = () : void => {
// 打开自定义二次确认弹窗
showConfirm.value = true
}
// 确认退出与取消继续扫码的回调
const onConfirmExit = () : void => {
showConfirm.value = false
doClose()
}
const onCancelExit = () : void => {
showConfirm.value = false
}
const connectWebSocket = () : void => {
try {
socketFd.value = ''
// 使用SocketTask对象管理WebSocket连接
socketTask = uni.connectSocket({
url: WS_URL,
success: (res : any) => {
console.log('WebSocket 连接请求成功')
},
fail: (err : any) => {
console.log('WebSocket 连接请求失败:', err)
}
})
// 创建本地引用避免智能转换问题
const currentSocketTask = socketTask
if (currentSocketTask != null) {
// 监听连接打开
currentSocketTask.onOpen((res) => {
console.log('WebSocket 连接已打开')
})
// 监听消息接收
currentSocketTask.onMessage((res) => {
try {
console.log('res',res)
let data = res['data']
// 正确处理消息数据类型
let messageData : string = ''
if (typeof data === 'string') {
messageData = data as string
} else {
console.log('收到非字符串消息:', data)
return
}
// 解析JSON消息
const obj = JSON.parse(messageData) as UTSJSONObject
console.log('收到消息类型:', obj)
const fdNum = obj.getNumber('fd') ?? 0
const type = obj.getString('type') ?? ''
socketFd.value = fdNum.toString()
if(type == 'clientId'){
getQrcode('?fd=' + socketFd.value)
} else {
let objdata = obj.data as UTSJSONObject
console.log('objdata:', objdata)
let imageSrc = objdata.getString('path') ?? ''
// 收到上传完成消息后直接关闭,无需用户二次确认
doClose()
uni.navigateTo({
url: '/pages/camera/index?imageSrc=' + imageSrc
})
}
} catch (e) {
console.log('解析WebSocket消息失败:', e)
}
})
// 监听连接错误
currentSocketTask.onError((err : any) => {
console.log('WebSocket 连接错误:', err)
})
// 监听连接关闭
currentSocketTask.onClose((res : any) => {
console.log('WebSocket 已关闭')
socketTask = null
})
}
} catch (e) {
console.log('发起WebSocket连接失败:', e)
}
}
// 监听 visible 属性,打开时连接 WebSocket,成功后获取二维码;关闭时清理
watch(() : boolean => props.visible, (newVisible : boolean) : void => {
if (newVisible) {
qrcode.value = ''
connectWebSocket()
startCountdown()
// 弹窗打开时,延迟一帧触发动画
nextTick(() => {
showAnimation.value = true
})
} else {
clearTimers()
closeSocket()
// 弹窗关闭时,重置动画状态
showAnimation.value = false
}
})
onUnmounted(() => {
clearTimers()
closeSocket()
})
</script>
<style>
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.3s ease;
}
.popup-overlay-enter {
background-color: rgba(0, 0, 0, 0.5);
}
.popup-fixed {
margin-top: 50rpx;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
transform: scale(0.7);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.popup-scale-enter {
transform: scale(1);
}
.popup-container {
width: 498.61rpx;
height: 547.92rpx;
position: relative;
z-index: 2;
}
.popup-container-bg {
display: flex;
justify-content: center;
position: relative;
width: 497.22rpx;
height: 633.33rpx;
}
.popup-title {
margin-top: 32rpx;
font-weight: bold;
font-size: 36rpx;
color: #21061A;
align-items: center;
display: flex;
justify-content: center;
text-align: center;
}
.popup-bg-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.icon-hand,
.icon-lightning {
font-size: 18rpx;
}
/* 倒计时区域 */
.timer-section {
margin-top: 30rpx;
padding: 12rpx 20rpx 8rpx;
align-items: center;
}
.timer-text {
font-weight: 400;
font-size: 20rpx;
color: #A9A9B2;
}
/* 主题指示器 */
.theme-section {
display: flex;
align-items: center;
justify-content: center;
padding: 16rpx 40rpx;
flex-direction: row;
}
.theme-line {
height: 2rpx;
background-color: #ddd;
width: 43rpx;
}
.theme-text {
white-space: nowrap;
font-weight: 400;
font-size: 19rpx;
color: #000000;
}
/* 警告信息 */
.warning-section {
display: flex;
align-items: center;
justify-content: center;
padding: 16rpx 40rpx;
}
.warning-icon {
color: #ff4444;
font-size: 60rpx;
}
.warning-text {
font-weight: 400;
font-size: 21rpx;
color: #F42733;
}
/* 二维码区域 */
.qr-section {
/* padding: 40rpx; */
display: flex;
align-items: center;
}
.qr-code-image {
width: 188rpx;
height: 189rpx;
}
.qr-instruction {
margin-top: 33rpx;
font-weight: bold;
font-size: 23rpx;
color: #000000;
align-items: center;
;
}
/* 关闭按钮 */
.close-button {
margin-top: 45rpx;
width: 52rpx;
height: 52rpx;
}
.close-icon {
width: 52rpx;
height: 52rpx;
}
</style>
\ No newline at end of file
<template>
<view v-if="visible" class="popup-overlay" :class="{ 'popup-overlay-enter': showAnimation }">
<view class="popup-overlay__content" :class="{ 'popup-scale-enter': showAnimation }" @click="handleContentClick">
<view class="lang-container">
<!-- 顶部留白占位,视觉更贴近设计稿 -->
<view class="lang-body">
<!-- #ifdef APP -->
<scroll-view class="list-scroll">
<view v-for="item in langList" :key="item.value" class="lang-item" @click="handleSelect(item)">
<text class="lang-text">{{ item.label }}</text>
<text v-if="selectedValue == item.value" class="check-text">✔</text>
</view>
<!-- 调试信息:显示数据长度 -->
<view v-if="langList.length == 0" class="debug-info">
<text class="debug-text">暂无语言数据</text>
</view>
</scroll-view>
<!-- #endif -->
</view>
<view class="action-bar">
<button class="btn-cancel" hover-class="hover_btn" @click="handleCancel">
取消
</button>
<button class="btn-confirm" hover-class="hover_btn" @click="handleConfirm">
确定
</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { LanguageOption } from '../../types/index.uts'
// 语言选项类型统一在 types/language.uts 中定义
// 组件属性(避免与其它文件的 Props 命名冲突)
interface LangProps {
visible : boolean
closeOnOverlay : boolean
languages : Array<LanguageOption> | null
modelValue : string | null
}
// 组件事件
interface Emits {
(e : 'close') : void
(e : 'cancel') : void
(e : 'confirm', value : string) : void
(e : 'update:modelValue', value : string) : void
(e : 'change', value : string) : void
}
const props = withDefaults(defineProps<LangProps>(), {
visible: false,
closeOnOverlay: false
})
const emit = defineEmits<Emits>()
// 最终用于渲染的语言列表,只使用从父组件传入的数据
const langList = computed<Array<LanguageOption>>(() => {
if (props.languages != null && props.languages.length > 0) {
return props.languages as Array<LanguageOption>
}
// 如果没有传入数据,返回空数组
return [] as Array<LanguageOption>
})
const selectedValue = ref<string>((props.modelValue != null) ? (props.modelValue as string) : 'zh-Hans')
const showAnimation = ref(false)
// 监听外部传入的 modelValue 变化
const modelValueRef = computed<string | null>(() => props.modelValue as string | null)
watch(modelValueRef, (nv : string | null, _ov : string | null) : void => {
if (nv != null) {
selectedValue.value = nv as string
}
})
// 监听外部传入的 languages 变化,用于调试
const languagesRef = computed<Array<LanguageOption> | null>(() => props.languages)
watch(languagesRef, (nv : Array<LanguageOption> | null, _ov : Array<LanguageOption> | null) : void => {
}, { immediate: true })
// 监听弹窗显示状态,控制动画
watch((): boolean => props.visible, (newVal: boolean): void => {
if (newVal) {
// 弹窗打开时,延迟一帧触发动画
nextTick(() => {
showAnimation.value = true
})
} else {
// 弹窗关闭时,重置动画状态
showAnimation.value = false
}
})
const handleOverlayClick = () => {
if (props.closeOnOverlay == true) {
emit('close')
}
}
const handleContentClick = () => {
// 阻止事件冒泡
}
const handleClose = () => {
emit('close')
}
const handleCancel = () => {
emit('cancel')
handleClose()
}
const handleConfirm = () => {
emit('confirm', selectedValue.value)
emit('update:modelValue', selectedValue.value)
handleClose()
}
const handleSelect = (item : LanguageOption) => {
selectedValue.value = item.value
emit('update:modelValue', item.value)
emit('change', item.value)
}
</script>
<style>
/* 遮罩层,与其它弹窗保持一致 */
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.3s ease;
}
.popup-overlay-enter {
background-color: rgba(0, 0, 0, 0.5);
}
.popup-overlay__content {
margin-top: 50rpx;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
transform: scale(0.7);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.popup-scale-enter {
transform: scale(1);
}
/* 容器 */
.lang-container {
width: 640rpx;
background-color: #FFFFFF;
border-radius: 24rpx;
padding: 32rpx 24rpx 24rpx 24rpx;
box-shadow: 0 12rpx 48rpx rgba(0, 0, 0, 0.2);
}
.lang-body {
max-height: 800rpx;
}
.list-scroll {
width: 100%;
height: 800rpx;
}
.list-view {
width: 100%;
}
.lang-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: 96rpx;
border-bottom: 1rpx solid #EEEEEE;
padding: 0 8rpx 0 8rpx;
}
.lang-text {
font-size: 28rpx;
color: #333333;
}
.check-text {
font-size: 28rpx;
color: #111111;
}
.action-bar {
margin-top: 24rpx;
display: flex;
flex-direction: row;
justify-content: space-around;
}
.btn-cancel {
width: 181rpx;
height: 83rpx;
background: #FFFFFF;
border-radius: 14rpx;
border: 2rpx solid #FF86C3;
font-family: Source Han Sans CN;
font-weight: bold;
font-size: 24rpx;
color: #000000;
margin-right: 20rpx;
line-height: 83rpx;
}
.btn-confirm {
width: 181rpx;
height: 83rpx;
background: #FD3DA0;
border-radius: 14rpx;
font-family: Source Han Sans CN;
font-weight: bold;
font-size: 24rpx;
color: #FFFFFF;
border: none;
line-height: 83rpx;
}
.debug-info {
padding: 20rpx;
text-align: center;
}
.debug-text {
font-size: 24rpx;
color: #999999;
}
</style>
\ No newline at end of file
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main"></script>
</body>
</html>
\ No newline at end of file
import App from './App.uvue'
import PopupConfirm from './components/Common/PopupConfirm.uvue'
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
// 注册全局组件
app.component('PopupConfirm', PopupConfirm)
return {
app
}
}
\ No newline at end of file
{
"name": "ColorAI",
"appid": "__UNI__8013F16",
"description": "AI拍照机,一款可将相片实时通过AI,模型转换成指定场景的设备",
"versionName": "1.1.28",
"versionCode": "112",
"uni-app-x": {},
/* 快应用特有相关 */
"quickapp": {},
/* 小程序特有相关 */
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false
},
"usingComponents": true
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics": {
"enable": false
},
"vueVersion": "3",
"app": {
"distribute": {
"icons": {
"android": {
"hdpi": "",
"xhdpi": "",
"xxhdpi": "",
"xxxhdpi": ""
}
}
}
},
"app-android": {
"distribute": {
"modules": {},
"icons": {
"hdpi": "C:/Users/20224/Documents/GitHub/ai-app-uniapp/static/logo.png",
"xhdpi": "C:/Users/20224/Documents/GitHub/ai-app-uniapp/static/logo.png",
"xxhdpi": "C:/Users/20224/Documents/GitHub/ai-app-uniapp/static/logo.png",
"xxxhdpi": "C:/Users/20224/Documents/GitHub/ai-app-uniapp/static/logo.png"
},
"splashScreens": {
"default": {}
}
}
},
"app-ios": {
"distribute": {
"modules": {},
"icons": {},
"splashScreens": {}
}
},
"web": {
"router": {
"mode": "",
"base": "/ai_camera"
}
}
}
\ No newline at end of file
{
"name": "ai-app-uniapp",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ai-app-uniapp",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"crypto-js": "^4.2.0"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
}
}
}
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/stop/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/list/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/camera/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/gener/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/posture/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path" : "pages/print/index",
"style" :
{
"navigationBarTitleText" : ""
}
},
{
"path": "pages/printlist/index",
"style": {
"navigationStyle": "custom"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "uni-app x",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8",
"navigationStyle": "custom"
},
"uniIdRouter": {}
}
<template>
<view class="camera-page" @touchstart="resetTimer">
<PageHeader @back="handleBack">照片拍摄</PageHeader>
<view class="card">
<view class="frame">
<text class="prompt" v-if="imageSrc == '' && !cameraInitializing">保持眼神注视镜头,身体静止状态</text>
<text class="prompt" v-if="imageSrc == '' && cameraInitializing">相机初始化中,请稍候...</text>
<view class="preview-host" style="height: 70%;" v-if="imageSrc == ''">
<!-- #ifdef APP -->
<camera :key="cameraPosition" class="camera" :device-position="cameraPosition" flash="off" mode="normal" resolution="high"
frame-size="large" @stop="handleStop" @error="handleError" @initdone="handleInitDone"></camera>
<image class="preview" src="/static/silhouette.png" mode="scaleToFill"></image>
<view v-if="countdownVisible" class="countdown-overlay">
<text class="countdown-number">{{ countdownNumber }}</text>
</view>
<!-- #endif -->
<!-- #ifdef H5 -->
<H5Camera ref="h5CameraRef" :imageSrc="imageSrc" @captured="handleH5Captured"
@retake="handleH5Retake" />
<!-- #endif -->
</view>
<view class="preview-host" style="height: 100%;" v-else>
<view class="refresh-host" @click="retake">
<image class="refresh-icon" src="/static/icon_refresh.png" mode="scaleToFill"></image>
</view>
<!-- 加载状态 -->
<view v-if="imageLoading" class="loading-overlay">
<image class="loading-gif" src="/static/waiting.gif" mode="aspectFit"></image>
<text class="loading-text">加载中..</text>
</view>
<image class="captured" :src="imageSrc" mode="widthFix" @load="handleImageLoad" @error="handleImageError"></image>
</view>
</view>
<view class="actions">
<button class="btn secondary" v-if="imageSrc != '' && isDirectPrint == 1" hover-class="hover_btn"
@click="print">打印原图</button>
<button class="btn pick-primary" v-if="imageSrc == '' && !autoConfirmInProgress" hover-class="hover_btn"
@click="confirm">拍照</button>
<button class="btn" :class="isDirectPrint == 0 ? 'primary-full' : 'primary'" v-if="imageSrc != ''"
hover-class="hover_btn" @click="submit">AI</button>
</view>
</view>
</view>
<PopupPrintov :visible="showPopupPrintov" />
<CategorySelector :visible="showCategorySelector" @close="handleCategorySelectorClose" @select="handleCategoryProductSelect" />
</template>
<script setup lang="uts">
import { ref } from 'vue'
import PageHeader from '../../components/Common/PageHeader.uvue'
import PopupPrintov from '../../components/Popup/PopupPrintov.uvue'
import CategorySelector from '../../components/Common/CategorySelector.uvue'
// #ifdef H5
import H5Camera from '../../components/Common/H5Camera.uvue'
// #endif
import { ossUpload, type ResponseData } from '../../utils/reques.uts'
import type { machineItems, categoryItem } from '../../types/globalData.uts'
let cameraContext : CameraContext | null = null
let inactivityTimer : number = 0
const INACTIVITY_TIMEOUT = 3 * 60 * 1000 // 3分钟
const imageSrc = ref<string>('')
// 图片加载状态
const imageLoading = ref<boolean>(false)
// 倒计时显示控制
const countdownVisible = ref<boolean>(false)
const countdownNumber = ref<number>(5)
let countdownTimerId : number | null = null
// 首次进入时自动拍照进行中标记,用于隐藏“拍照”按钮
const autoConfirmInProgress = ref<boolean>(false)
// 相机初始化状态
const cameraInitialized = ref<boolean>(false)
const cameraInitializing = ref<boolean>(false)
// 是否需要在初始化完成后自动拍照
const needAutoCapture = ref<boolean>(false)
// 是否需要镜像翻转(针对部分设备)
const needMirror = ref<boolean>(false)
// 摄像头位置('front' 或 'back')
const cameraPosition = ref<string>('back')
const showPopupPrintov = ref(false)
const showCategorySelector = ref(false)
const url = ref('')
// 是否显示打印原图按钮(0: 不显示, 1: 显示)
const isDirectPrint = ref<number>(0)
// #ifdef H5
const h5CameraRef = ref<InstanceType<typeof H5Camera> | null>(null)
// #endif
// 重置定时器 - 提前声明,供其他函数调用
const resetTimer = () => {
// 清除现有定时器
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
console.log('[camera] 重置无操作定时器,3分钟后自动退出')
// 启动新的定时器
inactivityTimer = setTimeout(() => {
console.log('[camera] 3分钟无操作,自动跳转到首页')
// 3分钟无操作,跳转到首页
uni.reLaunch({
url: '/pages/index/index'
})
}, INACTIVITY_TIMEOUT)
}
const handleBack = () => {
// 用户主动返回,清除定时器
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
uni.reLaunch({
url: '/pages/list/index'
})
}
const print = () => {
// 打开分类选择器
showCategorySelector.value = true
}
const handleStop = (e : UniCameraStopEvent) => {
}
const handleError = (e : UniCameraErrorEvent) => {
}
// 执行拍照(APP 原生)
const doTakePhoto = () => {
if (cameraContext == null) {
cameraContext = uni.createCameraContext()
}
cameraContext?.takePhoto({
quality: 'original',
selfieMirror: needMirror.value, // 前置摄像头使用selfieMirror参数控制拍照结果是否镜像
success: (res : CameraContextTakePhotoResult) => {
console.log('拍照成功', res)
const path = res.tempImagePath ?? ''
imageSrc.value = path
// 拍照成功后,先压缩图片
},
fail: (err : any) => {
// 失败也结束自动流程,恢复按钮显示
autoConfirmInProgress.value = false
}
} as CameraContextTakePhotoOptions)
}
// 开始5秒倒计时(内部逻辑)
const startCountdown = () => {
// 防重入
if (countdownVisible.value) {
return
}
countdownNumber.value = 5
countdownVisible.value = true
// 进入自动拍照流程,隐藏按钮
autoConfirmInProgress.value = true
if (countdownTimerId != null) {
clearInterval(countdownTimerId as number)
countdownTimerId = null
}
countdownTimerId = setInterval(() => {
if (countdownNumber.value > 1) {
countdownNumber.value = countdownNumber.value - 1
} else {
if (countdownTimerId != null) {
clearInterval(countdownTimerId as number)
countdownTimerId = null
}
countdownVisible.value = false
doTakePhoto()
}
}, 1000) as number
}
const handleInitDone = (e : UniCameraInitDoneEvent) => {
console.log('相机初始化完成')
cameraInitialized.value = true
cameraInitializing.value = false
// 如果需要自动拍照,则在初始化完成后开始倒计时
if (needAutoCapture.value) {
needAutoCapture.value = false
startCountdown()
}
}
// 开始5秒倒计时,结束后拍照
const confirm = () => {
// #ifdef H5
if (h5CameraRef.value) {
// H5 触发组件内部倒计时与拍照
h5CameraRef.value.startCountdown()
}
// #endif
// #ifdef APP
// 检查相机是否已初始化
if (!cameraInitialized.value) {
console.log('相机尚未初始化完成,等待初始化...')
// 标记需要自动拍照,等待初始化完成后执行
needAutoCapture.value = true
autoConfirmInProgress.value = true
return
}
startCountdown()
// #endif
}
onReady(() => {
// #ifdef APP
cameraContext = uni.createCameraContext()
cameraInitializing.value = true
// 首次进入且没有传入图片时,标记需要自动拍照,等待相机初始化完成
if (imageSrc.value == '' && autoConfirmInProgress.value) {
needAutoCapture.value = true
}
// #endif
// #ifdef H5
// H5 端相机无需等待初始化,直接触发自动拍照
if (imageSrc.value == '' && autoConfirmInProgress.value) {
confirm()
}
// #endif
})
onLoad((event : OnLoadOptions) => {
const incomingImageSrc = event["imageSrc"] ?? ""
imageSrc.value = incomingImageSrc
url.value = incomingImageSrc
// 如果有传入图片,显示加载状态
if (incomingImageSrc != '') {
imageLoading.value = true
}
// 读取 is_direct_print 的值
const storedValue = uni.getStorageSync('is_direct_print')
if (storedValue != null) {
isDirectPrint.value = storedValue as number
}
// 首次进入时,如果没有初始图片则隐藏拍照按钮并准备自动拍照
if (imageSrc.value == '') {
autoConfirmInProgress.value = true
} else {
autoConfirmInProgress.value = false
}
// 从缓存读取镜像设置和摄像头位置
const storedMirror = uni.getStorageSync('cameraMirror')
const storedPosition = uni.getStorageSync('cameraPosition')
// 处理 storedMirror 可能是字符串的情况
if (storedMirror != null) {
if (typeof storedMirror == 'boolean') {
needMirror.value = storedMirror as boolean
} else if (typeof storedMirror == 'string') {
needMirror.value = (storedMirror as string) == 'true'
} else {
needMirror.value = false
}
} else {
needMirror.value = false
}
cameraPosition.value = storedPosition != null ? storedPosition as string : 'back'
console.log('摄像头镜像状态:', needMirror.value)
console.log('摄像头位置:', cameraPosition.value)
// 启动无操作定时器
resetTimer()
})
onUnload(() => {
if (countdownTimerId != null) {
clearInterval(countdownTimerId as number)
countdownTimerId = null
}
// 清除无操作定时器
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
})
const retake = () => {
imageSrc.value = ''
// 重拍时标记需要自动拍照
autoConfirmInProgress.value = true
needAutoCapture.value = true
// #ifdef H5
if (h5CameraRef.value) {
h5CameraRef.value.retake()
// H5 端直接触发倒计时
setTimeout(() => {
confirm()
}, 100)
}
// #endif
// #ifdef APP
// 重拍时重新从缓存读取,确保使用正确的摄像头位置
const storedMirror = uni.getStorageSync('cameraMirror')
const storedPosition = uni.getStorageSync('cameraPosition')
// 处理 storedMirror 可能是字符串的情况
if (storedMirror != null) {
if (typeof storedMirror == 'boolean') {
needMirror.value = storedMirror as boolean
} else if (typeof storedMirror == 'string') {
needMirror.value = (storedMirror as string) == 'true'
} else {
needMirror.value = false
}
} else {
needMirror.value = false
}
cameraPosition.value = storedPosition != null ? storedPosition as string : 'back'
// 重拍时重新初始化相机上下文,确保后续拍照正常
cameraContext = uni.createCameraContext()
cameraInitialized.value = false
cameraInitializing.value = true
// 相机初始化完成后会自动触发倒计时(通过 needAutoCapture 标记)
// #endif
}
const submit = () => {
// 跳转到其他页面前清除定时器
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
// 对 imageSrc 进行 URL 编码,防止特殊字符影响 URL 解析
const encodedImageSrc = encodeURIComponent(imageSrc.value)
uni.navigateTo({
url: '/pages/gener/index?imageSrc=' + encodedImageSrc
})
}
// 关闭分类选择器
const handleCategorySelectorClose = () => {
showCategorySelector.value = false
}
// 处理分类选择器中的商品选择
const handleCategoryProductSelect = async (product : any) => {
console.log('[camera] 选中商品:', product)
showCategorySelector.value = false
// 跳转到其他页面前清除定时器
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
// 上传图片到OSS(如果还未上传)
let uploadedUrl = url.value
if (uploadedUrl == '') {
try {
const response = await ossUpload(imageSrc.value) as ResponseData
if (response.code == 0 && response.data != null) {
const data = response.data as UTSJSONObject
const file = data.getAny('file') as UTSJSONObject | null
uploadedUrl = file?.getString('url') ?? ''
url.value = uploadedUrl
console.log('[camera] 图片上传成功:', uploadedUrl)
} else {
console.error('[camera] 图片上传失败:', response.message)
uni.showToast({
title: '图片上传失败',
icon: 'none'
})
return
}
} catch (e) {
console.error('[camera] 图片上传异常:', e)
uni.showToast({
title: '图片上传异常',
icon: 'none'
})
return
}
}
// 将商品数据通过 URL 参数传递到打印页面
const productData = encodeURIComponent(JSON.stringify(product))
uni.navigateTo({
url: '/pages/print/index?imageSrc=' + encodeURIComponent(uploadedUrl) + '&productData=' + productData
})
}
// H5 拍照回调
const handleH5Captured = (dataUrl : string) => {
imageSrc.value = dataUrl
// 结束自动拍照流程
autoConfirmInProgress.value = false
}
// H5 重拍回调
const handleH5Retake = () => {
imageSrc.value = ''
// 重拍时恢复按钮显示
autoConfirmInProgress.value = false
}
// 图片加载完成
const handleImageLoad = () => {
imageLoading.value = false
}
// 图片加载失败
const handleImageError = () => {
imageLoading.value = false
}
</script>
<style>
.camera-page {
height: 100%;
}
.gradient-header {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0 36rpx;
padding-top: 56rpx;
height: 410rpx;
background: linear-gradient(to bottom, #FC81C0, #FFFFFF);
}
.back-btn {
width: 50rpx;
height: 50rpx;
border-radius: 25rpx;
background-color: rgba(255, 255, 255, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.back-icon {
width: 45rpx;
height: 45rpx;
}
.header-title {
font-weight: bold;
font-size: 25rpx;
color: #000000;
}
.card {
position: absolute;
top: 114rpx;
bottom: 0;
left: 0;
right: 0;
margin: 32rpx;
background: #f6f4f4;
border-radius: 28rpx;
padding: 20rpx 20rpx 56rpx 20rpx;
z-index: 0;
}
.prompt {
font-size: 22rpx;
color: #666666;
text-align: center;
padding-top: 50rpx;
}
.frame {
position: relative;
width: 100%;
height: 90%;
border-radius: 24rpx;
overflow: hidden;
background: #f6f4f4;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 40rpx;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
.preview-host {
position: relative;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
margin-top: auto;
margin-bottom: auto;
align-self: center;
justify-content: center;
align-items: center;
}
.camera {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
/* transform: scale(1.2); */
}
/* 修复部分设备镜像问题 */
.camera-mirror {
transform: scaleX(-1);
}
.captured {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.refresh-host {
position: absolute;
right: 10rpx;
top: 10rpx;
width: 32rpx;
height: 32rpx;
background: #FFFFFF;
border-radius: 26rpx;
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
}
.refresh-icon {
width: 16rpx;
height: 16rpx;
}
.preview {
position: relative;
width: 506rpx;
height: 560rpx;
pointer-events: none;
}
.countdown-overlay {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
}
.countdown-number {
font-weight: bold;
font-size: 180rpx;
color: #FFFFFF;
}
.loading-overlay {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: rgba(246, 244, 244, 0.9);
z-index: 5;
}
.loading-gif {
width: 120rpx;
height: 120rpx;
margin-bottom: 20rpx;
}
.loading-text {
font-size: 28rpx;
color: #666666;
}
.actions {
/* margin-top: 100rpx; */
display: flex;
flex-direction: row;
justify-content: center;
}
.btn {
border-radius: 16rpx;
font-size: 28rpx;
}
.secondary {
width: 181rpx;
height: 83rpx;
background: #FFFFFF;
border-radius: 14rpx;
border: 2rpx solid #FF86C3;
font-family: Source Han Sans CN;
font-weight: bold;
font-size: 24rpx;
color: #000000;
margin-right: 20rpx;
line-height: 83rpx;
}
.pick-primary {
width: 381rpx;
height: 83rpx;
background: #FD3DA0;
border-radius: 14rpx;
font-family: Source Han Sans CN;
font-weight: bold;
font-size: 24rpx;
color: #FFFFFF;
border: none;
line-height: 83rpx;
}
.primary {
width: 181rpx;
height: 83rpx;
background: #FD3DA0;
border-radius: 14rpx;
font-family: Source Han Sans CN;
font-weight: bold;
font-size: 24rpx;
color: #FFFFFF;
border: none;
line-height: 83rpx;
}
.primary-full {
width: 381rpx;
height: 83rpx;
background: #FD3DA0;
border-radius: 14rpx;
font-family: Source Han Sans CN;
font-weight: bold;
font-size: 24rpx;
color: #FFFFFF;
border: none;
line-height: 83rpx;
}
</style>
\ No newline at end of file
<template>
<view class="camera-page" @touchstart="resetTimer">
<PageHeader @back="handleBack">{{status == 'rendering' ? '作品生成中' : '作品已生成'}}</PageHeader>
<text class="time" v-if="status == 'rendering' && countdownSeconds > 0">预计还需 {{countdownSeconds}} 秒</text>
<text class="time" v-else-if="status == 'rendering' && countdownSeconds <= 0">正在完成最后处理…</text>
<view class="card">
<view class="image-container">
<!-- 原图片 - 始终显示作为底层 -->
<image class="card-image old-image" :src="imageSrc" mode="widthFix">
</image>
<!-- 新图片 - 从上往下逐渐显示 -->
<view v-if="status == 'success'" class="new-image-wrapper" :style="{ height: newImageHeight + '%' }">
<image class="card-image new-image" :src="renderImageSrc" mode="widthFix"
@load="handleNewImageLoad">
</image>
</view>
<!-- 扫描线 - 持续上下往返扫描(渲染中) -->
<image v-if="status == 'rendering'" class="scan-line"
:style="{ top: scanLineTop + '%', opacity: scanLineOpacity }" src="/static/scanning_line.png"
mode="scaleToFill"></image>
<!-- 过渡扫描线 - 跟随过渡进度(接口返回后立即显示) -->
<image v-if="status == 'success' && transitionScanLineTop < 100" class="transition-scan-line"
:style="{ top: transitionScanLineTop + '%', opacity: 1 }" src="/static/scanning_line.png"
mode="scaleToFill"></image>
</view>
</view>
<view v-if="status == 'success'" class="take-photo-btn" hover-class="hover_btn" @click="handlePrint">
<image class="take-photo-icon" src="/static/icon_print.png" /><text class="take-photo-text">打印</text>
</view>
</view>
<PopupConfirm :visible="showPopup" :title="popupTitle" :content="popupContent" :showCancel="popupShowCancel"
:confirmText="popupConfirmText" :cancelText="popupCancelText" @confirm="onPopupConfirm"
@cancel="onPopupCancel" />
<CategorySelector :visible="showCategorySelector" @close="handleCategorySelectorClose" @select="handleCategoryProductSelect" />
</template>
<script setup lang="uts">
import { ref } from 'vue'
import PageHeader from '../../components/Common/PageHeader.uvue'
import CategorySelector from '../../components/Common/CategorySelector.uvue'
import { ossUpload, uploadurl, queryRenderResult, cancel, type ResponseData } from '../../utils/reques.uts'
let inactivityTimer : number = 0
const INACTIVITY_TIMEOUT = 6 * 60 * 1000 // 6分钟
const imageSrc = ref('')
const status = ref('rendering')
const url = ref('')
const renderImageSrc = ref('')
// 弹窗相关
const showPopup = ref(false)
const popupTitle = ref('提示')
const popupContent = ref('')
const popupShowCancel = ref(false)
const popupConfirmText = ref('确定')
const popupCancelText = ref('取消')
const popupType = ref('') // 用于区分弹窗类型:'back' 或 'error'
// 保存当前任务的 prompt_id,便于取消
const currentPromptId = ref<string>('')
// 分类选择器显示控制
const showCategorySelector = ref(false)
let pollTimer : number | null = null
const countdownSeconds = ref(30) // 倒计时秒数,从30秒开始
let countdownTimer : number | null = null // 倒计时定时器
let pollStartTime : number = 0 // 轮询开始时间
const maxPollDuration = 180000 // 最大轮询时长:3分钟(180秒 = 180000毫秒)
let isRenderSuccess = false // 标记渲染是否已成功
// 新图过渡动画相关
const newImageHeight = ref(0) // 新图显示高度 0-100
const transitionDuration = 5000 // 过渡动画持续时间(毫秒)
let transitionTimer : number | null = null
const isNewImageLoaded = ref(false) // 新图是否加载完成
const transitionScanLineTop = ref(0) // 过渡扫描线位置 0-100
// 扫描线动画相关
const scanLineTop = ref(0) // 扫描线位置 0-100
const scanLineOpacity = ref(0) // 扫描线透明度 0-1
let scanLineTimer : number | null = null
const scanLineDuration = 10000 // 扫描线往返一次的时间(毫秒)
// 重置定时器 - 提前声明,供其他函数调用
const resetTimer = () => {
// 清除现有定时器
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
// 启动新的定时器
inactivityTimer = setTimeout(() => {
// 6分钟无操作,跳转到首页
uni.reLaunch({
url: '/pages/index/index'
})
}, INACTIVITY_TIMEOUT)
}
// 停止倒计时
const stopCountdown = () => {
if (countdownTimer != null) {
clearInterval(countdownTimer as number)
countdownTimer = null
}
}
// 开始倒计时
const startCountdown = () => {
countdownSeconds.value = 30
countdownTimer = setInterval(() => {
if (countdownSeconds.value > 0) {
countdownSeconds.value--
} else {
stopCountdown()
}
}, 1000) as number
}
// 停止扫描线动画
const stopScanLineAnimation = () => {
if (scanLineTimer != null) {
clearInterval(scanLineTimer as number)
scanLineTimer = null
}
}
// 开始扫描线动画 - 使用简化的往返逻辑
const startScanLineAnimation = () => {
stopScanLineAnimation()
scanLineTop.value = 0
scanLineOpacity.value = 0
let direction = 1 // 1: 向下, -1: 向上
const step = 2 // 每次移动2%
const interval = 20 // 20ms更新一次
scanLineTimer = setInterval(() => {
// 更新位置
scanLineTop.value += direction * step
// 计算透明度(顶部和底部渐变)
const pos = scanLineTop.value
if (pos < 5) {
scanLineOpacity.value = pos / 5
} else if (pos > 95) {
scanLineOpacity.value = (100 - pos) / 5
} else {
scanLineOpacity.value = 1
}
// 到达边界时反向
if (scanLineTop.value >= 100) {
scanLineTop.value = 100
direction = -1
} else if (scanLineTop.value <= 0) {
scanLineTop.value = 0
direction = 1
}
}, interval) as number
}
// 新图加载完成的处理
const handleNewImageLoad = () => {
isNewImageLoaded.value = true
}
// 开始新图过渡动画 - 从上往下显示
const startTransitionAnimation = () => {
// 停止扫描线动画
stopScanLineAnimation()
newImageHeight.value = 0
transitionScanLineTop.value = 0
isNewImageLoaded.value = false
status.value = 'success'
const interval = 20 // 20ms更新一次
const totalSteps = transitionDuration / interval
const stepProgress = 100 / totalSteps
let currentStep = 0
transitionTimer = setInterval(() => {
currentStep++
const progress = Math.min(100, currentStep * stepProgress)
// 扫描线始终跟随进度
transitionScanLineTop.value = progress - 2
// 如果图片已加载,新图也跟随显示
if (isNewImageLoaded.value) {
newImageHeight.value = progress
}
if (currentStep >= totalSteps) {
if (transitionTimer != null) {
clearInterval(transitionTimer as number)
transitionTimer = null
}
transitionScanLineTop.value = 100
newImageHeight.value = 100
}
}, interval) as number
}
// 停止过渡动画
const stopTransitionAnimation = () => {
if (transitionTimer != null) {
clearInterval(transitionTimer as number)
transitionTimer = null
}
stopScanLineAnimation()
}
// 停止轮询
const stopPolling = () => {
if (pollTimer != null) {
clearInterval(pollTimer as number)
pollTimer = null
}
}
// 清理所有定时器
const cleanupAllTimers = () => {
stopPolling()
stopCountdown()
stopTransitionAnimation()
uni.hideLoading()
}
// 显示错误弹窗
const showErrorPopup = (content : string) => {
// 如果已经成功,不显示错误弹窗
if (isRenderSuccess) {
console.log('已成功,不显示错误弹窗');
return
}
console.log('显示错误弹窗:' + content);
stopPolling()
stopCountdown()
stopScanLineAnimation()
uni.hideLoading()
popupType.value = 'error'
popupTitle.value = '提示'
popupContent.value = content
popupShowCancel.value = false
popupConfirmText.value = '确定'
showPopup.value = true
}
const startPolling = (promptId : string) => {
pollStartTime = Date.now()
isRenderSuccess = false
// 扫描线动画和倒计时已在 ossUpload 成功后启动,这里不再重复启动
const check = async () : Promise<void> => {
// 如果已经成功,不再执行检查
if (isRenderSuccess) {
console.log('已成功,跳过检查');
return
}
// 检查是否超过3分钟
const elapsedTime = Date.now() - pollStartTime
console.log('轮询检查,已用时:' + Math.floor(elapsedTime / 1000) + '秒');
if (elapsedTime >= maxPollDuration) {
console.log('超过3分钟,显示错误弹窗');
showErrorPopup('抱歉,当前相片与风格无法匹配,请返回重试')
return
}
try {
const res = await queryRenderResult(promptId) as ResponseData
// 接口返回后再次检查是否已成功,避免竞态条件
if (isRenderSuccess) {
return
}
if (res.code == 0) {
const dataJson = res.data as UTSJSONObject
const statusObj = dataJson['status_value'] as UTSJSONObject | null
const value : string = statusObj != null ? (statusObj.getString('value') ?? '') : ''
if (value == 'rendering') {
status.value = 'rendering'
if (scanLineTimer == null) {
startScanLineAnimation()
}
} else if (value == 'success') {
console.log('渲染成功,停止轮询');
isRenderSuccess = true // 标记为成功
stopPolling()
stopCountdown()
const renderImage : string = dataJson.getString('render_image') ?? ''
url.value = renderImage
renderImageSrc.value = renderImage
startTransitionAnimation()
} else {
showErrorPopup('抱歉,当前相片与风格无法匹配,请返回重试')
}
} else {
stopPolling()
stopCountdown()
uni.hideLoading()
}
} catch (err) {
// 捕获异常后也检查是否已成功
if (!isRenderSuccess) {
showErrorPopup('图片生成失败,请联系管理员')
}
}
}
check()
pollTimer = (setInterval(() : void => { check() }, 3000) as number)
}
const postrender = async (ai_model_id : number,) => {
const imagePath = imageSrc.value ?? ''
// 判断是否为网络地址
const isNetworkUrl = imagePath.startsWith('http://') || imagePath.startsWith('https://')
let imageUrl = imagePath
let imageName = ''
if (!isNetworkUrl) {
// 本地图片,先上传到 OSS
try {
const ossRes = await ossUpload(imagePath) as ResponseData
if (ossRes.code == 0) {
const ossData = ossRes.data as UTSJSONObject
const fileObj = ossData.getJSON('file')
if (fileObj != null) {
imageUrl = fileObj.getString('url') ?? ''
imageName = fileObj.getString('origin_name') ?? ''
// OSS上传成功后立即开始扫描线动画和倒计时
startScanLineAnimation()
startCountdown()
} else {
showErrorPopup('图片上传失败,请重试')
return
}
} else {
showErrorPopup(ossRes.message)
return
}
} catch (error) {
uni.hideLoading()
showErrorPopup('图片上传失败,请重试')
return
}
} else {
// 从URL中提取文件名(最后一个/后面的全部内容)
const lastSlashIndex = imagePath.lastIndexOf('/')
if (lastSlashIndex != -1 && lastSlashIndex < imagePath.length - 1) {
imageName = imagePath.substring(lastSlashIndex + 1)
}
// 网络图片也立即开始扫描线动画和倒计时
startScanLineAnimation()
startCountdown()
}
// 使用 uploadurl 接口进行渲染
const params = {
ai_model_id: ai_model_id,
image: imageUrl,
image_name: imageName
}
try {
const res = await uploadurl(params) as ResponseData
if (res.code == 0) {
const dataJson = res.data as UTSJSONObject
const promptId : string = dataJson.getString('prompt_id') ?? ''
if (promptId.length > 0) {
// 记录 prompt_id,供取消接口使用
currentPromptId.value = promptId
status.value = 'rendering'
startPolling(promptId)
}
} else {
showErrorPopup(res.message)
}
} catch (error) {
console.log('error',error)
showErrorPopup('抱歉,当前相片与风格无法匹配,请返回重试')
}
}
const handlePrint = () => {
// 打开分类选择器
showCategorySelector.value = true
}
// 关闭分类选择器
const handleCategorySelectorClose = () => {
showCategorySelector.value = false
}
// 处理分类选择器中的商品选择
const handleCategoryProductSelect = (product : any) => {
console.log('[gener] 选中商品:', product)
showCategorySelector.value = false
// 跳转到其他页面前清除定时器
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
// 使用生成后的图片 URL(renderImageSrc)
const uploadedUrl = url.value
// 将商品数据通过 URL 参数传递到打印页面
const productData = encodeURIComponent(JSON.stringify(product))
uni.navigateTo({
url: '/pages/print/index?imageSrc=' + encodeURIComponent(uploadedUrl) + '&productData=' + productData
})
}
onLoad((event : OnLoadOptions) => {
// 对接收到的 imageSrc 进行 URL 解码
const rawImageSrc = event["imageSrc"] ?? ''
const decoded = rawImageSrc.length > 0 ? decodeURIComponent(rawImageSrc) : ''
imageSrc.value = decoded != null ? decoded : ''
const selectedObj = uni.getStorageSync('selectedItem') as UTSJSONObject
const ai_model_id : number = selectedObj.getNumber('id') ?? 0
postrender(ai_model_id,)
// 启动无操作定时器
resetTimer()
})
const handleBack = () => {
popupType.value = 'back'
popupTitle.value = '温馨提示'
popupContent.value = '作品生成消耗算力,确认退出吗?'
popupShowCancel.value = true
popupConfirmText.value = '确定'
popupCancelText.value = '取消'
showPopup.value = true
}
// 弹窗确认处理
const onPopupConfirm = async () => {
showPopup.value = false
if (popupType.value == 'back') {
cleanupAllTimers()
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
uni.reLaunch({ url: '/pages/list/index' })
} else if (popupType.value == 'error') {
cleanupAllTimers()
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
uni.reLaunch({ url: '/pages/index/index' })
}
}
// 弹窗取消处理
const onPopupCancel = () => {
showPopup.value = false
if (popupType.value == 'error') {
cleanupAllTimers()
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
uni.reLaunch({ url: '/pages/index/index' })
}
}
onUnload(() => {
cleanupAllTimers()
// 清除无操作定时器
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
})
</script>
<style>
.camera-page {
height: 100%;
}
.time {
position: absolute;
top: 150rpx;
left: 0;
right: 0;
text-align: center;
align-items: center;
font-weight: 400;
font-size: 25rpx;
color: #000000;
}
.card {
position: absolute;
top: 170rpx;
bottom: 0;
right: 0;
background: #FFEAF5;
margin-top: 30rpx;
border-radius: 53rpx;
z-index: 0;
width: 657rpx;
height: 854rpx;
left: 50%;
transform: translateX(-50%);
}
.image-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 53rpx;
}
.card-image {
width: 100%;
height: 100%;
}
.old-image {
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
/* 新图包裹层 - 从上往下逐渐显示 */
.new-image-wrapper {
position: absolute;
top: 0;
left: 0;
right: 0;
overflow: hidden;
z-index: 2;
}
.new-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 854rpx;
}
/* 扫描线 - 持续上下往返扫描 */
.scan-line {
position: absolute;
left: 0;
width: 100%;
height: 40rpx;
pointer-events: none;
z-index: 10;
}
/* 过渡扫描线 - 跟随新图显示进度 */
.transition-scan-line {
position: absolute;
left: 0;
width: 100%;
height: 30rpx;
pointer-events: none;
z-index: 3;
}
.card-host {
margin-top: 20rpx;
width: 181.25rpx;
height: 9.03rpx;
}
.card-text {
margin-top: 44rpx;
font-weight: 400;
font-size: 25rpx;
color: #000000;
align-items: center;
}
.take-photo-btn {
width: 657rpx;
height: 97rpx;
background: #FD3DA0;
border-radius: 14rpx;
flex-direction: row;
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin: 0 auto;
position: fixed;
bottom: 120rpx;
left: 50%;
transform: translateX(-50%);
}
.take-photo-icon {
margin-right: 20rpx;
width: 29.86rpx;
height: 27.08rpx;
}
.take-photo-text {
font-weight: bold;
font-size: 28rpx;
color: #FFFFFF;
}
.actions {
margin-top: 704rpx;
display: flex;
flex-direction: row;
justify-content: center;
}
.btn {
width: 180rpx;
height: 80rpx;
border-radius: 16rpx;
font-size: 28rpx;
}
.primary {
width: 181rpx;
height: 83rpx;
background: #FFFFFF;
border-radius: 14rpx;
border: 2rpx solid #FF86C3;
font-weight: 400;
font-size: 24rpx;
color: #000000;
margin-right: 20rpx;
line-height: 83rpx;
}
</style>
\ No newline at end of file
<template>
<scroll-view class="content-card" @click="handleStart" scroll-y="true" refresher-enabled="true"
:refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<!-- 轮播图背景 -->
<swiper class="background-swiper" :indicator-dots="false" :autoplay="true" :interval="5000" :duration="500"
:circular="true">
<swiper-item v-for="(item, index) in adslist" :key="index">
<image class="background-image" :src="item.image" mode="aspectFill"></image>
</swiper-item>
</swiper>
<!-- 右上角设备信息按钮 -->
<view class="device-info-btn" @click.stop="handleDeviceInfo">
<text class="device-info-icon">ⓘ</text>
</view>
<!-- <view class="language" @click="handleLang" hover-class="hover_btn">
<image class="lang-icon" src="/static/lang_icon.png" mode="aspectFill"></image>
<text class="lang-text">{{ currentLangLabel }}</text>
</view> -->
<view @click.stop="handleQueue" hover-class="hover_btn" class="queue">
<image class="queue-icon" src="/static/liebiao.png" mode="aspectFit" />
</view>
<button class="start-btn" hover-class="hover_btn" @click="handleStart">点击屏幕开始体验</button>
</scroll-view>
<Popuplang :visible="showLang" :closeOnOverlay="true" :languages="languageslist" v-model:modelValue="selectedLang"
@close="showLang = false" @cancel="showLang = false" @confirm="onLangConfirm" />
<PopupBind :Bindvisible="showBind" :closeOnOverlay="true" @close="showBind = false" @success="onBindSuccess" />
<PopupDeviceInfo :visible="showDeviceInfo" :closeOnOverlay="true" :deviceInfo="machineInfo"
@close="showDeviceInfo = false" />
<AppUpgrade :visible="upgradeVisible" :close-on-overlay="true" :version-info="appVersionInfo"
@close="upgradeVisible=false" />
<PopupPrintList :visible="showQueueList" :closeOnOverlay="true" @close="showQueueList=false" />
</template>
<script setup lang="ts">
import Popuplang from '../../components/Popup/Popuplang.uvue'
import PopupBind from '../../components/Popup/PopupBind.uvue'
import PopupDeviceInfo from '../../components/Popup/PopupDeviceInfo.uvue'
import AppUpgrade from '../../components/Common/AppUpgrade.uvue'
import PopupPrintList from '../../components/Popup/PopupPrintList.uvue'
import { LanguageOption, AdsOption } from '../../types/index.uts'
import { languages, getMachineApp, ads, type ResponseData } from '../../utils/reques.uts'
// 弹窗相关
const showLang = ref(false)
const showBind = ref(false)
const showDeviceInfo = ref(false)
const upgradeVisible = ref<boolean>(false)
const appVersionInfo = ref<UTSJSONObject | null>(null)
const machineInfo = ref<UTSJSONObject | null>(null)
const showQueueList = ref(false)
// 设备信息按钮点击计数
const deviceInfoClickCount = ref<number>(0)
const deviceInfoClickTimer = ref<number | null>(null)
const animationTimer = ref<number | null>(null)
// 下拉刷新状态
const refreshing = ref<boolean>(false)
// 语言列表数据
const languageslist = ref<Array<LanguageOption>>([])
const selectedLang = ref<string>('zh-Hans')
//获取轮播图广告
const adslist = ref<Array<AdsOption>>([])
// 设置默认广告图片
const setDefaultAd = () => {
const defaultAd : AdsOption = {
image: '/static/index.png'
}
adslist.value = [defaultAd]
}
const getads = async () => {
const params = {} as object
try {
const res = await ads(params) as ResponseData
if (res.code as number == 0) {
// 将 res.data 转换为数组以安全访问属性
const dataArray = res.data as Array<UTSJSONObject>
const adsItems : Array<AdsOption> = []
const length = dataArray.length as number
for (let i = 0; i < length; i++) {
const item = dataArray[i] as UTSJSONObject
const adsItem : AdsOption = {
image: item.getString('content') ?? '',
}
adsItems.push(adsItem)
}
// 如果获取到广告数据,则使用,否则使用默认图片
if (adsItems.length > 0) {
adslist.value = adsItems
} else {
setDefaultAd()
}
} else {
setDefaultAd()
}
} catch (error) {
setDefaultAd()
}
}
// 获取语言列表的方法
const getLanguages = async () => {
const params = {} as object
try {
const res = await languages(params) as ResponseData
if (res.code as number == 0) {
// 将 res.data 转换为 UTSJSONObject 以安全访问属性
const dataObj = res.data as UTSJSONObject
const dataArray = dataObj.getArray('list') as Array<any>
const languageItems : Array<LanguageOption> = []
for (let i = 0; i < dataArray.length; i++) {
const item = dataArray[i] as UTSJSONObject
const languageItem : LanguageOption = {
label: item.getString('label') ?? '',
value: item.getString('value') ?? ''
}
languageItems.push(languageItem)
}
languageslist.value = languageItems
}
} catch (error) {
console.log('获取语言列表失败:', error)
}
}
// 版本比较与展示判断(兼容纯数字或 x.y.z 格式)
const isDigitsOnly = (s : string) : boolean => {
if (s.length == 0) return false
for (let i = 0; i < s.length; i++) {
const ch = s.charCodeAt(i)
if (ch != null && (ch < 48 || ch > 57)) return false
}
return true
}
const safeToNumber = (s : string) : number => {
if (isDigitsOnly(s)) {
return parseInt(s)
}
return 0
}
const splitVersion = (v : string) : Array<number> => {
const parts = v.split('.')
const nums : Array<number> = []
for (let i = 0; i < parts.length; i++) {
nums.push(safeToNumber(parts[i]))
}
return nums
}
const compareVersions = (a : string, b : string) : number => {
// 若均为纯数字,直接比较大小
if (isDigitsOnly(a) && isDigitsOnly(b)) {
const ai = parseInt(a)
const bi = parseInt(b)
if (ai < bi) return -1
if (ai > bi) return 1
return 0
}
// 否则按 x.y.z 语义化比较
const A = splitVersion(a)
const B = splitVersion(b)
const maxLen = A.length > B.length ? A.length : B.length
for (let i = 0; i < maxLen; i++) {
const ai = i < A.length ? A[i] : 0
const bi = i < B.length ? B[i] : 0
if (ai < bi) return -1
if (ai > bi) return 1
}
return 0
}
const shouldShowUpgrade = (current : string, remote : string) : boolean => {
if (remote.length == 0) return false
if (current.length == 0) return true
return compareVersions(current, remote) < 0
}
const getMachineinfo = async () => {
const params = {} as object
try {
const res = await getMachineApp(params) as ResponseData
if (res.code as number == 0) {
// 将 res.data 转换为 UTSJSONObject 以安全访问属性
const dataObj = res.data as UTSJSONObject
// 保存完整的设备信息
machineInfo.value = dataObj
// 存储 is_direct_print 的 value
if (dataObj.is_direct_print != null) {
const isDirectPrintObj = dataObj.is_direct_print as UTSJSONObject
const isDirectPrintValue = isDirectPrintObj.getNumber('value') ?? 0
uni.setStorage({
key: 'is_direct_print',
data: isDirectPrintValue
})
}
// 解析并传递版本信息给升级组件
// #ifdef APP
if (dataObj.app_version != null) {
const appObj = dataObj.app_version as UTSJSONObject
const remoteVersion = appObj.getString('version') ?? ''
const downloadUrl = appObj.getString('download_url') ?? ''
appVersionInfo.value = appObj
// 读取当前应用版本
const base = uni.getAppBaseInfo()
const currentVersion = base.appVersion ?? ''
if (remoteVersion.length > 0 && downloadUrl.length > 0) {
if (shouldShowUpgrade(currentVersion, remoteVersion)) {
upgradeVisible.value = true
}
}
}
// #endif
}
} catch (error) {
console.log('获取机器信息失败:', error)
}
}
// 队列执行函数 - 按顺序执行,如果某个失败则停止后续执行
const executeQueue = async () => {
try {
// 1. 获取广告
await getads()
// 2. 获取机器信息
await getMachineinfo()
// 3. 获取语言列表
await getLanguages()
} catch (error) {
console.log('队列执行失败:', error)
throw error
}
}
// 下拉刷新处理函数
const onRefresh = async () => {
refreshing.value = true
try {
await executeQueue()
} catch (error) {
console.log('刷新失败:', error)
} finally {
// 延迟关闭刷新状态,确保用户能看到刷新动画
setTimeout(() => {
refreshing.value = false
}, 500)
}
}
onLoad((event : OnLoadOptions) => {
// 获取 URL 参数
// 在 WEB 环境下处理 URL 参数
// #ifdef WEB
// 先设置默认广告,确保有内容显示
if (event != null) {
// 检查是否有 machine_code 参数
const urlMachineCode = event['machine_code'] as string | null
if (urlMachineCode != null && urlMachineCode != '') {
// 将 URL 参数中的 machine_code 保存到缓存
uni.setStorageSync('machine_code', urlMachineCode)
executeQueue()
} else {
setDefaultAd()
}
}
// #endif
// uni.removeStorage({
// key: 'machine_code',
// success: function (res) {
// console.log('success');
// }
// });
})
// #ifdef APP
onShow(() => {
// 每次进入页面都执行 getMachineinfo
uni.removeStorageSync('selectedItem')
const machine_code = uni.getStorageSync('machine_code')
if (machine_code != null && machine_code != '') {
executeQueue()
} else {
// 没有 machine_code 时显示默认广告
setDefaultAd()
showBind.value = true
}
})
// #endif
onUnmounted(() => {
// 清理定时器
const timer = deviceInfoClickTimer.value
if (timer != null) {
clearTimeout(timer)
deviceInfoClickTimer.value = null
}
})
const handleStart = () => {
uni.navigateTo({
url: '/pages/list/index',
fail: (err) => {
console.log('导航失败:', err)
}
})
}
const currentLangLabel = computed<string>(() => {
let result : string = '简体中文'
for (let i = 0; i < languageslist.value.length; i++) {
const it : LanguageOption = languageslist.value[i]
if (it.value == selectedLang.value) {
result = it.label
break
}
}
return result
})
const handleLang = () => {
showLang.value = true
}
const handleQueue = () => {
showQueueList.value = true
}
const handleDeviceInfo = () => {
// 增加点击计数
deviceInfoClickCount.value++
// 清除之前的定时器
const timer = deviceInfoClickTimer.value
if (timer != null) {
clearTimeout(timer)
}
// 如果达到5次点击,打开设备信息弹窗
if (deviceInfoClickCount.value >= 5) {
showDeviceInfo.value = true
deviceInfoClickCount.value = 0 // 重置计数
deviceInfoClickTimer.value = null
return
}
// 设置2秒后重置计数的定时器
deviceInfoClickTimer.value = setTimeout(() => {
deviceInfoClickCount.value = 0
deviceInfoClickTimer.value = null
}, 2000)
}
const onLangConfirm = (value : string) => {
selectedLang.value = value
showLang.value = false
}
// 绑定成功的处理函数
const onBindSuccess = () => {
// 绑定成功后重新获取机器信息和语言列表
executeQueue()
}
</script>
<style>
.content-card {
width: 100%;
height: 100%;
position: relative;
}
.background-swiper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.background-image {
width: 100%;
height: 100%;
}
.device-info-btn {
position: absolute;
opacity: 0.05;
top: 10rpx;
right: 10rpx;
width: 80rpx;
height: 80rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 9999rpx;
display: flex;
justify-content: center;
align-items: center;
z-index: 2;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
}
.device-info-icon {
font-size: 36rpx;
color: #333333;
font-weight: bold;
}
.language {
position: absolute;
top: 60rpx;
right: 0;
z-index: 2;
height: 74rpx;
padding: 0 10rpx;
background: #FFFFFF;
border-radius: 37rpx 0rpx 0rpx 37rpx;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.lang-icon {
margin: 0 10rpx;
width: 34.72rpx;
height: 35.42rpx;
}
.lang-text {
font-weight: 400;
font-size: 39rpx;
color: #333333;
}
.queue {
position: absolute;
top: 300rpx;
right: -15rpx;
z-index: 2;
height: 55rpx;
width: 81rpx;
padding: 0 10rpx;
border-radius: 37rpx 0rpx 0rpx 37rpx;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.queue-icon {
width: 100%;
height: 100%;
}
.start-btn {
position: fixed;
bottom: 20%;
left: 50%;
transform: translateX(-50%);
width: 263.89rpx;
height: 83.33rpx;
background: linear-gradient(to bottom, #FD3C9F, #FCA7D3);
font-family: Source Han Sans CN;
font-weight: bold;
font-size: 25rpx;
color: #000000;
line-height: 83.33rpx;
border-radius: 20rpx;
z-index: 2;
}
</style>
\ No newline at end of file
<template>
<view class="list-container">
<image class="background-image" src="/static/listbg.png" mode="aspectFill"></image>
<view class="back-header">
<view class="btn-back">
<view class="back-btn" hover-class="hover_btn" @click="handleBack">
<image class="back-icon" src="/static/icon_back.png" mode="aspectFill"></image>
</view>
<!-- <button class="back-upload" @click="handleUpload">照片上传</button> -->
</view>
<view class="list-title">
<text class="title-line">请选择</text>
<text class="title-line">你喜欢的风格</text>
</view>
<input class="search-input" v-model="searchValue" placeholder-class="search-input-placeholder"
placeholder="请输入作品类型、作者名称" @input="onSearchInput" @confirm="onSearchConfirm" />
</view>
<scroll-view class="tabs-container" direction="horizontal" :show-scrollbar="showTabsScrollbar" :scroll-left="tabsScrollLeft" @scroll="onTabsScroll" @scrollend="onTabsScrollEnd" scroll-with-animation="true" :scroll-into-view="scrollIntoViewId">
<view class="tabs-group" v-if="tabs.length > 0">
<view class="tabs" id="tab-all" @click="changeActiveTab('all')" :class="{ active: activeTab == 'all' }">
<text class="tab-item" :class="{ active: activeTab == 'all' }">{{ t('全部') }}</text>
</view>
<view class="tabs" v-for="item in tabs" :key="item.value" :id="'tab-' + formatTabId(item.value)"
@click="changeActiveTab(item.value)" :class="{ active: activeTab == item.value }">
<text class="tab-item" :class="{ active: activeTab == item.value }">{{ item.label }}</text>
</view>
</view>
</scroll-view>
<!-- 瀑布流区域 -->
<scroll-view class="waterfall-container" direction="vertical" @scrolltolower="onScrollToLower"
refresher-enabled="true" :refresher-triggered="isRefreshing" @refresherrefresh="onRefresh"
v-if="!isLoading || adslist.length > 0">
<!-- 数据加载完成后显示瀑布流 -->
<view class="waterfall-columns" v-if="!isLoading && adslist.length > 0">
<view class="waterfall-column" v-for="columnIndex in 3" :key="columnIndex">
<view class="waterfall-item" v-for="(item, index) in getColumnItems(columnIndex - 1)" :key="item.id"
:style="{ backgroundColor: getItemBackground(item.id) }"
@click="handleItemClick(item)">
<image class="item-image" :src="item.style_image" mode="widthFix" lazy-load="true"
@load="onImageLoad(item.id)" @error="onImageError(item.id)"></image>
</view>
</view>
</view>
<!-- 加载更多提示 -->
<view class="loading-more" v-if="isLoadingMore">
<text class="loading-text">加载中...</text>
</view>
<!-- 没有更多数据提示 -->
<view class="no-more" v-if="noMoreData && adslist.length > 0">
<text class="no-more-text">没有更多数据了</text>
</view>
</scroll-view>
<!-- 初始加载状态 -->
<view class="initial-loading" v-if="isLoading && adslist.length == 0">
<image class="loading-gif" src="/static/Loading.gif" mode="aspectFit"></image>
<text class="loading-text">加载中...</text>
</view>
<!-- 空状态显示 -->
<view class="empty-state" v-if="!isLoading && (meta.total == 0 || hasError) && adslist.length == 0">
<image class="empty-icon" src="/static/error_black.png" mode="aspectFit"></image>
</view>
<!-- 弹窗组件 -->
<PopupCheck :visible="showPopup" @close="handleClosePopup" @upload="handleUploadFromPopup"
@take-photo="handleTakePhotoFromPopup" />
<PopupUpload :visible="showPopupUpload" :close-on-overlay="true" @close="handleClosePopupUpload" />
</view>
</template>
<script setup>
import { ref } from 'vue'
import { category, list, ossUpload } from '../../utils/reques.uts'
import type { ResponseData } from '../../utils/reques.uts'
import PopupCheck from '../../components/Popup/PopupCheck.uvue'
import PopupUpload from '../../components/Popup/PopupUpload.uvue'
// 定义标签项的类型接口
type TabItem = {
label : string
value : string
}
// 定义列表项的类型接口
type listItem = {
style_image : string
id : number
}
type metaData = {
total : number
records : number
}
// 弹窗显示状态
const showPopup = ref(false)
const showPopupUpload = ref(false)
const searchValue = ref('')
const activeTab = ref<string>('all')
// H5 横向滚动控制与可视化
const showTabsScrollbar = ref<boolean>(true)
const tabsScrollLeft = ref<number>(0)
// 横向滚动到可视区域的目标 id
const scrollIntoViewId = ref<string>('tab-all')
const tabs = ref<Array<TabItem>>([])
const adslist = ref<Array<listItem>>([])
const meta = ref<metaData>({
total: 0,
records: 0
})
// 添加 imageSrc 和 url 变量
const imageSrc = ref('')
const url = ref('')
// 随机占位背景色列表(未加载完成时使用)
const PLACEHOLDER_COLORS = [
'#ab372f', '#f15642', '#692a1b', '#eeaa9c', '#64483d',
'#ea8958', '#e8b49a', '#be7e4a', '#9a8878', '#f9e9cd',
'#806332', '#b7ae8f', '#b7d07a', '#1a6840', '#40a070',
'#10aec2', '#806d9e', '#a35c8f', '#c35691', '#d13c74'
] as Array<string>
// 每个 item 的加载状态和占位背景色映射,按 id 存储
const itemLoaded = ref<Map<number, boolean>>(new Map<number, boolean>())
const itemBgColors = ref<Map<number, string>>(new Map<number, string>())
// 随机取色(严格返回字符串)
function pickRandomColor() : string {
const len : number = PLACEHOLDER_COLORS.length
const idx : number = Math.floor(Math.random() * len)
return PLACEHOLDER_COLORS[idx]
}
// 为新列表项初始化占位背景色和加载状态
function ensureItemMappings(items : Array<listItem>) : void {
for (let i = 0; i < items.length; i++) {
const id : number = items[i].id
if (!itemBgColors.value.has(id)) {
itemBgColors.value.set(id, pickRandomColor())
}
if (!itemLoaded.value.has(id)) {
itemLoaded.value.set(id, false)
}
}
}
// 供模板使用:返回当前 item 的背景色
function getItemBackground(id : number) : string {
const loaded : boolean = itemLoaded.value.get(id) ?? false
if (loaded) {
return '#FFFFFF'
}
const color = itemBgColors.value.get(id)
return color != null ? color : '#FFFFFF'
}
// 定义分页参数类型
type PageParams = {
search : string
value : string
limit : number
page : number
}
// 分页和加载状态管理
const pageParams = ref<PageParams>({
search: '',
value: '',
limit: 10,
page: 1
})
const isLoading = ref(false)
const isLoadingMore = ref(false)
const isRefreshing = ref(false)
const noMoreData = ref(false)
const hasError = ref(false) // 添加请求失败状态
// 防止 H5 端生命周期重复触发导致初始化调用两次
const hasInit = ref<boolean>(false)
const t = (s : string) : string => s
// 规范化生成 tab 的 id,避免非法字符影响滚动定位
function formatTabId(value : string) : string {
let res : string = ''
for (let i = 0; i < value.length; i++) {
const ch : string = value.charAt(i)
const code : number = value.charCodeAt(i) ?? 0
const isUpper : boolean = code >= 65 && code <= 90
const isLower : boolean = code >= 97 && code <= 122
const isDigit : boolean = code >= 48 && code <= 57
if (isUpper || isLower || isDigit || ch == '_' || ch == '-') {
res += ch
} else {
res += '-'
}
}
return res
}
// tabs 区域滚动事件(用于 H5 调试与确保绑定生效)
function onTabsScroll(e : UniScrollEvent) : void {
const d = e.detail
// 同步横向滚动位置,便于编程控制
tabsScrollLeft.value = d.scrollLeft
}
function onTabsScrollEnd(_e : UniScrollEvent) : void {
// 可按需处理滚动结束逻辑
}
// 获取分类数据的函数
const getcategory = async () : Promise<void> => {
const params = {} as object
try {
const res : ResponseData = await category(params) as ResponseData
if (res.code == 0) {
// 正确处理 UTSJSONObject 数组到 TabItem 数组的转换
const dataArray = res.data as Array<any>
const tabItems : Array<TabItem> = []
for (let i = 0; i < dataArray.length; i++) {
const item = dataArray[i] as UTSJSONObject
const tabItem : TabItem = {
label: item.getString('label') ?? '',
value: item.getString('value') ?? ''
}
tabItems.push(tabItem)
}
tabs.value = tabItems
}
} catch (error) {
// 分类请求失败,保留现有 tabs 状态即可
console.log('获取分类失败:')
}
}
const getlist = async (isLoadMore : boolean) : Promise<void> => {
// 如果是加载更多,设置加载更多状态
if (isLoadMore) {
if (isLoadingMore.value || noMoreData.value) return
isLoadingMore.value = true
} else {
// 首次加载或刷新
isLoading.value = true
pageParams.value.page = 1
noMoreData.value = false
hasError.value = false // 重置错误状态
// 刷新时重置映射,避免旧状态干扰
itemLoaded.value = new Map<number, boolean>()
itemBgColors.value = new Map<number, string>()
}
// 构建请求参数
const params = {
value: pageParams.value.value,
search: pageParams.value.search,
limit: pageParams.value.limit,
page: pageParams.value.page
} as object
try {
const res : ResponseData = await list(params) as ResponseData
if (res.code == 0) {
// 检查meta是否为null或undefined
// 正确处理 meta 数据的赋值
if (res.meta != null) {
const metaObj = res.meta as UTSJSONObject
const newMeta: metaData = {
total: metaObj.getNumber('total') ?? 0,
records: metaObj.getNumber('records') ?? 0
}
meta.value = newMeta
// console.log('newMeta',newMeta);
}
// 正确处理 UTSJSONObject 数组到 listItem 数组的转换
const dataArray = res.data as Array<any>
const listItems : Array<listItem> = []
// console.log('dataArray',dataArray);
for (let i = 0; i < dataArray.length; i++) {
const item = dataArray[i] as UTSJSONObject
const listItemData : listItem = {
style_image: item.getString('style_image') ?? '' + '?x-oss-process=image/resize,lfit,w_60',
id: item.getNumber('id') ?? 0
}
listItems.push(listItemData)
}
if (isLoadMore) {
// 初始化新追加项的占位颜色与加载状态
ensureItemMappings(listItems)
// 加载更多:追加数据
adslist.value = [...adslist.value, ...listItems]
// 如果返回的数据少于limit,说明没有更多数据了
if (listItems.length < pageParams.value.limit) {
noMoreData.value = true
}
} else {
// 首次加载或刷新:替换数据
ensureItemMappings(listItems)
adslist.value = listItems
// 首屏如果返回的数据不足一页,则直接标记为没有更多,防止触底立即再次请求
if (listItems.length < pageParams.value.limit) {
noMoreData.value = true
}
}
}
} catch (error) {
console.log('获取列表失败:', error)
hasError.value = true // 设置请求失败状态
} finally {
isLoading.value = false
isLoadingMore.value = false
isRefreshing.value = false
}
}
onLoad(() => {
// H5 有时会重复触发页面初始化,这里加一次性防护
if (hasInit.value == true) {
return
}
hasInit.value = true
getcategory()
getlist(false)
});
onUnload(() => {
// 页面卸载时重置初始化标记,便于下次进入重新初始化
hasInit.value = false
})
const changeActiveTab = (tabValue : string) : void => {
activeTab.value = tabValue
pageParams.value.value = tabValue
if (tabValue == 'all') {
pageParams.value.value = ''
}
// 切换标签后,滚动到对应标签位置
scrollIntoViewId.value = tabValue == 'all' ? 'tab-all' : 'tab-' + formatTabId(tabValue)
// 清空列表数据,以便显示初始加载状态
adslist.value = []
getlist(false)
}
const handleBack = () => {
uni.reLaunch({
url: '/pages/index/index'
})
}
const handleUpload = () => {
showPopup.value = true
}
// 标签点击处理,切换激活标签
const handleTab1Click = (id : string) : void => {
activeTab.value = id
}
// 处理弹窗关闭
const handleClosePopup = () => {
showPopup.value = false
}
// 处理弹窗中的上传操作
const handleUploadFromPopup = async () => {
// 选择手机上传 -> 检测平台
showPopup.value = false
// #ifdef WEB
// H5 端先选择图片
try {
uni.chooseImage({
count: 1,
sizeType: ['original', 'compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempFilePaths = res.tempFilePaths
if (tempFilePaths.length > 0) {
imageSrc.value = tempFilePaths[0]
// 执行上传
try {
const uploadRes = await ossUpload(imageSrc.value) as ResponseData
console.log('uploadRes', uploadRes);
if (uploadRes.code == 0) {
// 解析返回的数据
const data = uploadRes.data as UTSJSONObject
url.value = data?.getString('url') ?? ''
// 使用 encodeURIComponent 编码 URL 参数
const encodedUrl = encodeURIComponent(url.value)
console.log('准备导航到 camera 页面,URL:', '/pages/camera/index?imageSrc=' + encodedUrl)
uni.navigateTo({
url: '/pages/camera/index?imageSrc=' + encodedUrl,
success: () => {
console.log('导航成功')
},
fail: (err) => {
console.log('导航到相机页面失败:', err)
uni.showToast({
title: '页面跳转失败',
icon: 'none'
})
}
})
} else {
uni.showToast({
title: '上传失败,请重试',
icon: 'error'
})
}
} catch (error) {
console.log('ossUpload 失败:', error)
uni.showToast({
title: '上传失败,请重试',
icon: 'error'
})
}
}
},
fail: (error) => {
console.log('取消:', error)
uni.showToast({
title: '取消',
icon: 'none'
})
}
})
return
} catch (error) {
console.log('H5 选择图片异常:', error)
}
// #endif
// 非 H5 端或异常时,显示上传提示弹窗
showPopupUpload.value = true
}
// 处理弹窗中的拍照操作
const handleTakePhotoFromPopup = () => {
showPopup.value = false
uni.navigateTo({
url: '/pages/posture/index',
fail: (err) => {
console.log('导航到拍照页面失败:', err)
uni.showToast({
title: '页面跳转失败',
icon: 'none'
})
}
})
}
// Upload 弹窗关闭
const handleClosePopupUpload = () => {
showPopupUpload.value = false
}
// 滚动到底部触发加载更多
const onScrollToLower = () : void => {
// 触底加载更多:避免与首屏加载并发;仅在非加载中且有更多数据时触发
if (isLoading.value || isLoadingMore.value || noMoreData.value) {
return
}
pageParams.value.page = pageParams.value.page + 1
getlist(true)
}
// 下拉刷新
const onRefresh = () : void => {
isRefreshing.value = true
pageParams.value.page = 1
getlist(false)
}
// 执行搜索
function performSearch() : void {
pageParams.value.search = searchValue.value
pageParams.value.page = 1
noMoreData.value = false
getlist(false)
}
// 搜索输入处理
let searchTimer : number | null = null
function onSearchInput(event : UniInputEvent) : void {
// 防抖处理,避免频繁请求
if (searchTimer != null) {
clearTimeout(searchTimer as number)
}
searchTimer = setTimeout(() => {
performSearch()
}, 1000) // 防抖
}
// 搜索确认处理
function onSearchConfirm() : void {
if (searchTimer != null) {
clearTimeout(searchTimer as number)
}
performSearch()
}
// 瀑布流相关方法
// 将数据分配到三列中
const getColumnItems = (columnIndex : number) : Array<listItem> => {
const columnItems : Array<listItem> = []
for (let i = columnIndex; i < adslist.value.length; i += 3) {
columnItems.push(adslist.value[i])
}
return columnItems
}
// 处理图片点击事件
const handleItemClick = (item : listItem) : void => {
uni.setStorage({
key: 'selectedItem',
data: {
id: item.id,
content: item.style_image
}
})
showPopup.value = true
}
// 图片加载成功:标记该 item 已加载,背景色切回白色
const onImageLoad = (id : number) : void => {
itemLoaded.value.set(id, true)
}
// 图片加载失败:保持随机占位色
const onImageError = (id : number) : void => {
itemLoaded.value.set(id, false)
}
</script>
<style scoped>
.list-container {
width: 100%;
/* height: 492.36rpx; */
position: relative;
display: flex; /* 页面根容器采用flex布局,便于子容器flex:1生效 */
flex-direction: column; /* 垂直方向布局 */
height: 100%; /* 撑满页面高度,确保滚动容器可计算高度 */
}
.background-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
/* height: 100%; */
height: 492.36rpx;
z-index: 1;
}
.back-btn {
width: 50rpx;
height: 50rpx;
border-radius: 25rpx;
background-color: rgba(255, 255, 255, 0.5);
/* 最后一个参数 0~1 表示透明度 */
}
.back-header {
padding: 0 38rpx;
padding-top: 33rpx;
position: relative;
z-index: 2;
}
.btn-back {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.back-btn {
width: 50rpx;
height: 50rpx;
border-radius: 25rpx;
background-color: rgba(255, 255, 255, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.back-icon {
width: 45rpx;
height: 45rpx;
}
.search-input {
margin-top: 18rpx;
width: 422rpx;
height: 56rpx;
background: rgba(255, 255, 255, 0.36);
border-radius: 28rpx;
font-weight: bold;
font-size: 19rpx;
color: #333333;
padding: 0 28rpx;
}
.search-input-placeholder {
height: 100%;
line-height: 56rpx;
}
.back-upload {
width: 102rpx;
height: 44rpx;
border-radius: 14rpx;
border: 1px solid #FD3DA0;
background-color: #fd4196;
font-weight: bold;
font-size: 16rpx;
color: #FFFFFF;
line-height: 44rpx;
}
.list-title {
margin-top: 40rpx;
}
.list-title .title-line {
font-weight: bold;
font-size: 55rpx;
color: #000000;
}
.tabs-container {
height: 40rpx;
margin: 19rpx 34rpx 1rpx 34rpx;
z-index: 1;
display: flex;
flex-direction: row;
width: auto;
white-space: nowrap;
}
.tabs-container::-webkit-scrollbar {
display: none;
}
.tabs-group {
display: flex;
flex-wrap: nowrap;
flex-direction: row;
/* 移除宽度限制,允许横向展开 */
width: auto;
/* 确保子元素不会换行 */
white-space: nowrap;
}
.tabs {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
margin-right: 16rpx;
}
.tab-item.active {
color: #FD3C9F;
}
.tab-item {
padding: 10rpx;
font-weight: bold;
font-size: 25rpx;
color: #3D3B3B;
}
/* 瀑布流区域 */
.waterfall-container {
flex: 1;
padding: 0 30rpx;
margin-top: 20rpx;
position: relative;
z-index: 99;
}
.waterfall-columns {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.waterfall-column {
width: 32%;
display: flex;
flex-direction: column;
}
.waterfall-item {
background: #FFFFFF;
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
margin-bottom: 20rpx;
width: 100%;
max-height: 350rpx;
}
.item-image {
width: 100%;
height: auto;
min-height: 200rpx;
display: flex;
}
/* 加载状态样式 */
.loading-more {
display: flex;
justify-content: center;
align-items: center;
padding: 30rpx 0;
width: 100%;
}
.loading-text {
font-size: 24rpx;
color: #999999;
}
.no-more {
display: flex;
justify-content: center;
align-items: center;
padding: 30rpx 0;
width: 100%;
}
.no-more-text {
font-size: 24rpx;
color: #cccccc;
}
.item-content {
padding: 20rpx;
}
.item-title {
font-size: 28rpx;
color: #333333;
font-weight: bold;
line-height: 1.4;
}
/* 空状态样式 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 300rpx 0;
flex: 1;
}
.empty-icon {
width: 300rpx;
height: 300rpx;
margin-bottom: 30rpx;
}
.empty-text {
font-size: 28rpx;
color: #999999;
text-align: center;
}
/* 初始加载状态样式 */
.initial-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
flex: 1;
position: relative;
z-index: 99;
}
.loading-gif {
width: 140rpx;
height: 140rpx;
margin-bottom: 15rpx;
}
</style>
\ No newline at end of file
<template>
<view class="take-photo-container" @touchstart="resetTimer">
<image class="background-image" src="/static/takebg.png" mode="aspectFill"></image>
<view class="back-btn" hover-class="hover_btn" @click="handleBack">
<image class="back-icon" src="/static/icon_back.png" mode="aspectFill"></image>
</view>
<text class="header-title">艺术照拍摄</text>
<view class="main-content">
<view class="left-section">
<image class="wrong-img" src="/static/wrong.png" />
<image class="right-img" src="/static/icon_right.png" />
<text class="section-text">五官清晰,正面半身照</text>
</view>
<view class="right-section">
<view class="error-examples">
<view class="error-example">
<image class="error-img" src="/static/wrong_one.png" />
<image class="left-img" src="/static/icon_wrong.png" />
<view>不是正面</view>
</view>
<view style="height: 22rpx"></view>
<view class="error-example">
<image class="error-img" src="/static/wrong_two.png" />
<image class="left-img" src="/static/icon_wrong.png" />
<view>有遮挡</view>
</view>
<view style="height: 22rpx"></view>
<view class="error-example">
<image class="error-img" src="/static/wrong_three.png" />
<image class="left-img" src="/static/icon_wrong.png" />
<view>过于模糊</view>
</view>
</view>
</view>
</view>
<view class="bottom-section">
<view class="take-photo-btn" hover-class="hover_btn" @click="take">
<image class="take-photo-icon" src="/static/icon_takepictures.png" /><text
class="take-photo-text">点击拍照</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
let inactivityTimer : number = 0
const INACTIVITY_TIMEOUT = 3 * 60 * 1000 // 3分钟
const handleBack = () => {
uni.navigateBack()
}
const take = () => {
uni.navigateTo({
url: '/pages/camera/index'
})
}
// 重置定时器
const resetTimer = () => {
// 清除现有定时器
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
}
// 启动新的定时器
inactivityTimer = setTimeout(() => {
// 3分钟无操作,跳转到首页
uni.reLaunch({
url: '/pages/index/index'
})
}, INACTIVITY_TIMEOUT)
}
// 页面加载时启动定时器
onLoad(() => {
resetTimer()
})
// 页面隐藏时清除定时器
onHide(() => {
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
})
// 页面卸载时清除定时器
onUnload(() => {
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
})
</script>
<style>
.take-photo-container {
height: 100%;
background-color: #f8f8f8;
display: flex;
flex-direction: column;
position: relative;
}
.background-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.back-btn {
margin-top: 56rpx;
margin-left: 36rpx;
width: 50rpx;
height: 50rpx;
border-radius: 25rpx;
background-color: rgba(255, 255, 255, 0.5);
display: flex;
justify-content: center;
align-items: center;
position: relative;
z-index: 1;
}
.back-icon {
width: 45rpx;
height: 45rpx;
}
.header-title {
margin-top: 31rpx;
font-weight: bold;
font-size: 36rpx;
color: #000000;
text-align: center;
}
/* 头部样式 */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 40rpx 48rpx;
}
.exit-btn {
color: #333;
font-size: 32rpx;
padding: 16rpx 24rpx;
border-radius: 12rpx;
}
.title {
display: flex;
align-items: center;
font-size: 36rpx;
font-weight: bold;
color: #333;
}
/* 主要内容区域 */
.main-content {
margin-top: 81rpx;
display: flex;
justify-content: space-evenly;
flex-direction: row;
height: 50%;
position: relative;
z-index: 1;
}
/* 左侧区域 */
.left-section {
position: relative;
width: 370.83rpx;
height: 508.33rpx;
overflow: visible;
}
.wrong-img {
width: 100%;
height: 100%;
}
.right-img {
position: absolute;
bottom: -40rpx;
right: -20rpx;
width: 66rpx;
height: 66rpx;
}
.section-text {
font-weight: bold;
font-size: 21rpx;
color: #333333;
text-align: center;
line-height: 2.8em;
}
/* 右侧区域 */
.right-section {
width: 30%;
height: 90%;
}
.error-examples {
display: flex;
flex-direction: column;
align-items: center;
}
/* 删除 mt-22 工具类,改为内联占位 view 实现间距 */
.error-example {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: visible;
font-weight: 400;
font-size: 19rpx;
color: #333333;
}
.error-img {
width: 139.58rpx;
height: 136.81rpx;
margin-bottom: 10rpx;
}
.left-img {
position: absolute;
bottom: 10rpx;
right: -10rpx;
width: 40.28rpx;
height: 40.28rpx;
}
.Loading {
position: fixed;
z-index: 10001;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.98);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 76rpx;
font-family: Source Han Sans CN;
font-weight: 400;
color: #333333;
box-shadow: 0 0 100rpx rgba(0, 0, 0, 0.1);
}
/* 底部区域 */
.bottom-section {
padding: 48rpx;
height: 100%;
position: relative;
z-index: 1;
}
.take-photo-btn {
max-width: 500rpx;
border: none;
width: 407.64rpx;
height: 97.22rpx;
background: linear-gradient(to left, #FD3C9F, #FCA7D3);
border-radius: 20rpx;
flex-direction: row;
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin: 0 auto;
}
.take-photo-icon {
margin-right: 20rpx;
width: 29.86rpx;
height: 27.08rpx;
}
.take-photo-text {
font-size: 28rpx;
font-weight: bold;
color: #333333;
}
</style>
\ No newline at end of file
<template>
<view class="camera-page" :style="{ 'background-color': design_color }" @touchstart="resetTimer">
<view class="card">
<view class="back-header" :style="{ visibility: isEditing ? 'hidden' : 'visible' }">
<view class="btn-back">
<view class="back-btn" hover-class="hover_btn" @click="handleBack">
<image class="back-icon" src="/static/icon_back.png" mode="aspectFill"></image>
</view>
<text class="title-line">确认打印</text>
<view class="back-reset-btn" hover-class="hover_btn" @click="handleChangereset">
<image class="back-reset-icon" src="/static/icon_refresh.png" />
<text class="back-reset-text">重置</text>
</view>
</view>
</view>
<view class="replace" @click.stop="handleChangeProduct" hover-class="hover_btn"
:style="{ visibility: isEditing ? 'hidden' : 'visible' }">
<text class="replace-text">更换商品</text>
</view>
<!-- 纪念币裁剪区域 -->
<text class="prompt" :style="{ visibility: isEditing ? 'hidden' : 'visible' }">拖动作品,可调整打印区域</text>
<CroppImage ref="CroppImageRef" :avatar="imageSrc" :sizewidth="sizewidth" :sizeheight="sizeheight"
@cropData="handleCropData" :isCropping="Cropping" :categoryValue="category_value" :fixturebg="Fixturebg"
@editingChange="handleEditingChange" />
</view>
<view class="actions" :style="{ visibility: isEditing ? 'hidden' : 'visible' }">
<view class="take-photo-btn" hover-class="hover_btn" @click="handlePrint">
<image class="take-photo-icon" src="/static/icon_print.png" /><text class="take-photo-text">打印</text>
</view>
</view>
</view>
<PopupPrinting :visible="showPopupPrinting" printText="处理中..." />
<PopupConfirm :visible="showConfirmBack" title="温馨提示" content="作品已生成,请确认是否退出?" confirmText="确定" cancelText="取消"
@confirm="handleConfirmBack" @cancel="handleCancelBack" />
<CategorySelector :visible="showCategorySelector" @close="handleCategorySelectorClose" @select="handleCategoryProductSelect" />
</template>
<script setup lang="uts">
import { ref } from 'vue'
import PageHeader from '../../components/Common/PageHeader.uvue'
import CroppImage from '../../components/Common/CroppImage.uvue'
import PopupPrinting from '../../components/Popup/PopupPrinting.uvue'
import CategorySelector from '../../components/Common/CategorySelector.uvue'
// 本页负责裁剪、创建作品、创建订单后传参到打印列表页
import { creatework, createorder } from '../../utils/reques.uts'
import type { ResponseData } from '../../utils/reques.uts'
let inactivityTimer : number = 0
const INACTIVITY_TIMEOUT = 3 * 60 * 1000 // 3分钟
const category_value = ref('')
const design_color = ref('')
const imageSrc = ref('');
const ai_model_id = ref(0)
const machine_id = ref(0)
const Fixturebg = ref('')
const sizewidth = ref(0)
const sizeheight = ref(0)
// 裁剪组件引用
const CroppImageRef = ref<ComponentPublicInstance | null>(null)
const Cropping = ref(false)
// 存储裁剪数据
const cropInfoObj = ref<UTSJSONObject | null>(null)
const showPopupPrinting = ref(false)
// 判断是否显示更换商品按钮
// 编辑状态 - 用于控制其他元素的显示/隐藏
const isEditing = ref(false)
// 控制退出确认弹窗
const showConfirmBack = ref(false)
// 控制分类选择器显示
const showCategorySelector = ref(false)
const handleBack = () => {
showConfirmBack.value = true
}
// 确认退出
const handleConfirmBack = () => {
showConfirmBack.value = false
uni.reLaunch({
url: '/pages/list/index'
})
}
// 取消退出
const handleCancelBack = () => {
showConfirmBack.value = false
}
// 接收子组件传递的裁剪数据
const handleCropData = (cropInfo : UTSJSONObject) => {
cropInfoObj.value = cropInfo
console.log('[Print] 接收到裁剪数据:', cropInfo)
}
// 接收子组件传递的编辑状态
const handleEditingChange = (editing : boolean) => {
isEditing.value = editing
console.log('[Print] 编辑状态变化:', editing)
}
// 重置编辑
const handleChangereset = () => {
if (CroppImageRef.value != null) {
CroppImageRef.value.$callMethod('resetImage')
}
}
// 打开选择商品弹窗
const handleChangeProduct = () => {
// 打开分类选择器
showCategorySelector.value = true
}
// 关闭分类选择器
const handleCategorySelectorClose = () => {
showCategorySelector.value = false
}
// 处理分类选择器中的商品选择 - 更换商品参数
const handleCategoryProductSelect = (product : UTSJSONObject) => {
console.log('[print] 选中新商品:', product)
showCategorySelector.value = false
// 更新商品相关参数
ai_model_id.value = product.getNumber('value') ?? 0
machine_id.value = product.getNumber('machine_id') ?? 0
category_value.value = product.getString('category_value') ?? ''
const widthNum : number = parseFloat(product.getString('render_width') ?? '547')
const heightNum : number = parseFloat(product.getString('render_height') ?? '542')
sizewidth.value = isNaN(widthNum) ? 547 : widthNum
sizeheight.value = isNaN(heightNum) ? 542 : heightNum
Fixturebg.value = product.getString('front_image') ?? ''
design_color.value = product.getString('design_color') ?? ''
console.log('[print] 商品参数已更新:', {
id: ai_model_id.value,
machine_id: machine_id.value,
category: category_value.value,
size: `${sizewidth.value}x${sizeheight.value}`,
color: design_color.value
})
// 重置裁剪区域以适应新商品尺寸
handleChangereset()
}
// 打印确认处理函数(仅裁剪并跳转传参)
const handlePrint = async () => {
// 显示打印中弹窗
showPopupPrinting.value = true
// 触发裁剪信息收集
Cropping.value = true
// 延时 1s,确保子组件传递完成
await new Promise<void>((resolve) => {
setTimeout(() : void => {
resolve()
}, 1000)
})
// 使用组件传递的裁剪信息
const cropData = cropInfoObj.value
console.log('cropInfoObj', cropData);
let upper_left_x : number = 0
let upper_left_y : number = 0
let width : number = 0
let height : number = 0
let zoom : number = 1
let rotate : number = 0
upper_left_x = (cropData != null ? (cropData['imageX'] as number) : 0) ?? 0
upper_left_y = (cropData != null ? (cropData['imageY'] as number) : 0) ?? 0
width = (cropData != null ? (cropData['displayWidth'] as number) : 0) ?? 0
height = (cropData != null ? (cropData['displayHeight'] as number) : 0) ?? 0
zoom = (cropData != null ? (cropData['imageScale'] as number) : 1) ?? 1
rotate = (cropData != null ? (cropData['imageRotation'] as number) : 0) ?? 0
// 构造完整的 payload 对象并先请求 creatework
const payload = {
goods_id: ai_model_id.value,
machine_id: machine_id.value,
components: [
{
upper_left_x: upper_left_x,
upper_left_y: upper_left_y,
width: width,
height: height,
zoom: zoom,
rotate: rotate,
index: 0,
content: imageSrc.value,
type_value: 'image'
}
]
} as any
// console.log('creatework payload', payload)
try {
const workRes : ResponseData = await creatework(payload) as ResponseData
if (workRes.code != 0) {
throw new Error(workRes.message)
}
const dataObj = workRes.data as UTSJSONObject
const works_id = dataObj.getNumber('works_id') ?? 0
const image = dataObj.getString('image') ?? ''
const dpi = dataObj.getNumber('dpi') ?? 0
const render_cove_width = dataObj.getString('render_cove_width') ?? ''
const render_cove_height = dataObj.getString('render_cove_height') ?? ''
const render_left = dataObj.getString('render_left') ?? ''
const render_top = dataObj.getString('render_top') ?? ''
const goodsObj = dataObj.getJSON('goods') as UTSJSONObject | null
const goods = goodsObj
// 创建订单
const orderRes : ResponseData = await createorder({ works_id: works_id }) as ResponseData
console.log('createorder result', orderRes)
if (orderRes.code != 0) {
throw new Error(orderRes.message)
}
const order = orderRes.data as UTSJSONObject
const order_id = order.getNumber('order_id') ?? 0
console.log('order_id', order_id);
const work = {
works_id: works_id,
order_id: order_id,
image: image,
dpi: dpi,
render_cove_width: render_cove_width,
render_cove_height: render_cove_height,
render_left: render_left,
render_top: render_top,
goods: goods
} as any
console.log('navigate work', work)
// 关闭弹窗
showPopupPrinting.value = false
uni.navigateTo({ url: `/pages/printlist/index?work=${encodeURIComponent(JSON.stringify(work))}` })
} catch (e) {
console.error('creatework error', e)
// 关闭弹窗
showPopupPrinting.value = false
uni.showToast({
title: (e as Error).message,
icon: 'none'
})
}
}
// 重置定时器
const resetTimer = () => {
// 清除现有定时器
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
}
// 启动新的定时器
inactivityTimer = setTimeout(() => {
// 3分钟无操作,跳转到首页
uni.reLaunch({
url: '/pages/index/index'
})
}, INACTIVITY_TIMEOUT)
}
onLoad((event : OnLoadOptions) => {
// 从路由参数获取商品信息
const productData = event["productData"] ?? ''
if (productData.length > 0) {
try {
const decoded = decodeURIComponent(productData) ?? ''
const productJson = JSON.parse(decoded as string) as UTSJSONObject
ai_model_id.value = productJson.getNumber('value') ?? 0
machine_id.value = productJson.getNumber('machine_id') ?? 0
category_value.value = productJson.getString('category_value') ?? ''
const widthNum : number = parseFloat(productJson.getString('render_width') ?? '')
const heightNum : number = parseFloat(productJson.getString('render_height') ?? '')
sizewidth.value = isNaN(widthNum) ? 547 : widthNum
sizeheight.value = isNaN(heightNum) ? 542 : heightNum
Fixturebg.value = productJson.getString('front_image') ?? ''
design_color.value = productJson.getString('design_color') ?? ''
console.log('[print] 商品数据加载成功:', {
id: ai_model_id.value,
machine_id: machine_id.value,
category: category_value.value,
size: `${sizewidth.value}x${sizeheight.value}`
})
} catch (e) {
console.error('[print] 解析商品数据失败:', e)
uni.showToast({
title: '商品数据解析失败',
icon: 'none'
})
}
} else {
console.warn('[print] 未接收到商品数据')
}
const rawImageSrc = event["imageSrc"] ?? ''
const decoded = rawImageSrc.length > 0 ? decodeURIComponent(rawImageSrc) : ''
imageSrc.value = decoded != null ? decoded : ""
console.log('[print] 图片地址:', imageSrc.value)
// 启动无操作定时器
resetTimer()
})
// 页面隐藏时清除定时器
onHide(() => {
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
})
onUnload(() => {
// 清除无操作定时器
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
})
</script>
<style>
.camera-page {
height: 100%;
}
.back-header {
padding: 0 38rpx;
padding-top: 33rpx;
position: relative;
z-index: 2;
}
.btn-back {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.title-line {
font-weight: bold;
font-size: 25rpx;
color: #666666;
}
.back-reset-btn {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
background: #ffffff;
padding: 10rpx;
border-radius: 20rpx;
border: 2rpx solid #FD3DA0;
}
.back-reset-text {
font-size: 22rpx;
color: #000000;
}
.back-reset-icon {
width: 21rpx;
height: 21rpx;
margin: 0 5rpx;
}
.back-btn {
width: 60rpx;
height: 60rpx;
margin: 0 15rpx;
border-radius: 60rpx;
background-color: rgba(255, 255, 255, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.back-icon {
width: 60rpx;
height: 60rpx;
}
.replace {
position: absolute;
bottom: 300rpx;
right: 0;
z-index: 2;
height: 74rpx;
padding: 0 10rpx;
background: #FFFFFF;
border-radius: 37rpx 0rpx 0rpx 37rpx;
border: 1rpx solid #fd58ad;
border-right: none;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.replace-text {
font-weight: 400;
font-size: 39rpx;
color: #333333;
}
.time {
position: absolute;
top: 150rpx;
left: 0;
right: 0;
text-align: center;
align-items: center;
font-weight: 400;
font-size: 25rpx;
color: #000000;
}
.card {
height: 100%;
}
.prompt {
margin-top: 30rpx;
font-size: 22rpx;
color: #666666;
text-align: center;
}
.card-content {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.actions {
position: fixed;
bottom: 60rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 9;
}
.btn {
width: 180rpx;
height: 80rpx;
border-radius: 16rpx;
font-size: 28rpx;
}
.primary {
width: 181rpx;
height: 83rpx;
background: #FFFFFF;
border-radius: 14rpx;
border: 2rpx solid #FF86C3;
font-weight: bold;
font-size: 24rpx;
color: #000000;
margin-right: 20rpx;
line-height: 83rpx;
}
.resetprimary {
width: 181rpx;
height: 83rpx;
background: #179D10;
border-radius: 14rpx;
/* border: 2rpx solid #ffffffff; */
font-weight: bold;
font-size: 24rpx;
color: #ffffffff;
margin-right: 20rpx;
line-height: 83rpx;
}
.pickprimary {
margin-top: 51rpx;
width: 657rpx;
height: 97rpx;
background: #FD3DA0;
border-radius: 14rpx;
font-family: Source Han Sans CN;
font-weight: bold;
font-size: 24rpx;
color: #FFFFFF;
border: none;
line-height: 97rpx;
}
.take-photo-btn {
width: 657rpx;
height: 97rpx;
background: #FD3DA0;
border-radius: 14rpx;
flex-direction: row;
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin: 0 auto;
margin-top: 51rpx;
}
.take-photo-icon {
margin-right: 20rpx;
width: 29.86rpx;
height: 27.08rpx;
}
.take-photo-text {
font-weight: bold;
font-size: 28rpx;
color: #FFFFFF;
}
</style>
\ No newline at end of file
<template>
<view class="camera-page" :style="{ 'background-color': goods_design_color }" @touchstart="resetTimer">
<view class="back-header">
<view class="btn-back">
<view class="back-btn" hover-class="hover_btn" @click="handleBack">
<image class="back-icon" src="/static/icon_back.png" mode="aspectFill"></image>
</view>
<text class="title-line">确认打印</text>
<button style="width: 50rpx;"></button>
</view>
</view>
<text class="prompt">{{goods_name}}</text>
<view class="card">
<!-- 展示传入的图片预览 -->
<image class="goods_image" :style="infoStyle" :src="goods_front_image" mode="aspectFit" />
<image :style="infoStyle" :src="imageSrc" mode="aspectFit" />
</view>
<view class="actions">
<image class="loading" style="height:30rpx;width:50%" v-if="statusValue == 'waiting'"
src="/static/waiting.gif" mode="aspectFit" />
<image class="loading" v-else-if="statusValue == 'printing'" src="/static/icon_printing.gif"
mode="aspectFit" />
<image class="loading" v-else-if="statusValue == 'completed'" src="/static/Complete.gif" mode="aspectFit" />
<image class="loading" v-else src="/static/canceled.png" mode="aspectFit" />
<text class="printing" v-if="statusValue == 'waiting'">作品已生成,如需打印请联系工作人员
</text>
<text class="printing" v-else-if="statusValue == 'printing'">作品打印中...</text>
<text class="printing" v-else-if="statusValue == 'completed'">作品已打印,请及时取货</text>
<text class="printing" v-else>打印已取消</text>
<view class="take-photo-btn" hover-class="hover_btn" @click="handleOpenShare">
<image class="take-photo-icon" src="/static/icon_print.png" /><text class="take-photo-text">分享</text>
</view>
</view>
</view>
<PrintShare :visible="showPrintShare" title="分享作品" :worksId="worksId"
@update:visible="onPrintShareVisibleChange" />
<PopupConfirm :visible="showPopup" :title="popupTitle" :content="popupContent" :showCancel="false"
@confirm="onPopupConfirm" @cancel="onPopupCancel" />
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
import PageHeader from '../../components/Common/PageHeader.uvue'
import PopupPrinting from '../../components/Popup/PopupPrinting.uvue'
import PopupPrintov from '../../components/Popup/PopupPrintov.uvue'
import PrintShare from '../../components/Common/PrintShare.uvue'
import { createorder, print, queryStatus } from '../../utils/reques.uts'
import type { ResponseData } from '../../utils/reques.uts'
let inactivityTimer : number = 0
const INACTIVITY_TIMEOUT = 6 * 60 * 1000 // 6分钟
const imageSrc = ref(''); //展示图片
const worksId = ref(0) //作品id
const orderId = ref(0) //订单id
const goods_front_image = ref('') //模板设计图
const goods_name = ref('')
const goods_design_color = ref('')
const showPrintShare = ref(false)
const showPopup = ref(false)
const popupTitle = ref('提示')
const popupContent = ref('')
const screenWidth = ref(0)
const imageWidth = ref(0)
const imageHeight = ref(0)
const statusValue = ref('waiting') // 打印状态:waiting(等待), printing(打印中), completed(完成)
// 接收的 creatework 返回对象(来自上一页)
const incomingWork = ref<UTSJSONObject | null>(null)
// 重置定时器 - 提前声明,供其他函数调用
const resetTimer = () => {
// 清除现有定时器
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
// 启动新的定时器
inactivityTimer = setTimeout(() => {
uni.reLaunch({
url: '/pages/index/index'
})
}, INACTIVITY_TIMEOUT)
}
const handleOpenShare = () => {
resetTimer() // 用户点击分享时重置定时器
showPrintShare.value = true
}
const onPrintShareVisibleChange = (v : boolean) : void => {
resetTimer() // 分享弹窗状态变化时重置定时器
showPrintShare.value = v
}
// 全局轮询取消标志,页面关闭或跳转时停止轮询
let pollCancelled : boolean = false
// 轮询定时器ID
let pollTimer : number | null = null
// 完成弹窗自动关闭定时器(5秒)
let printovAutoCloseTimer : number | null = null
// 错误处理定时器
let errorAutoCloseTimer : number | null = null
const clearPollTimer = () : void => {
if (pollTimer != null) {
clearTimeout(pollTimer as number)
pollTimer = null
}
}
const clearErrorAutoClose = () : void => {
if (errorAutoCloseTimer != null) {
clearTimeout(errorAutoCloseTimer as number)
errorAutoCloseTimer = null
}
}
// 清理所有定时器的统一函数
const cleanupAllTimers = () : void => {
// 清除无操作定时器
if (inactivityTimer > 0) {
clearTimeout(inactivityTimer)
inactivityTimer = 0
}
// 停止轮询
pollCancelled = true
clearPollTimer()
clearErrorAutoClose()
}
const onPopupConfirm = () : void => {
showPopup.value = false
// 跳转前清除所有定时器
cleanupAllTimers()
// 跳转到首页
uni.reLaunch({
url: '/pages/index/index'
})
}
const onPopupCancel = () : void => {
showPopup.value = false
// 跳转前清除所有定时器
cleanupAllTimers()
// 跳转到首页
uni.reLaunch({
url: '/pages/index/index'
})
}
// 轮询配置:间隔与最长等待时间(毫秒)
const POLL_INTERVAL_MS : number = 5000 //每隔 5 秒会调用一次轮询查询
const MAX_POLL_TIME_MS : number = 360000 // 最长轮询6分钟
// 安全延时函数(UTS严格类型)- 使用可清理的定时器
const delay = (ms : number) : Promise<void> => {
return new Promise<void>((resolve) => {
pollTimer = setTimeout(() : void => {
pollTimer = null
resolve()
}, ms)
})
}
// 轮询订单状态:waiting -> printing -> completed/canceled
const pollOrderStatus = async (order_id : number) : Promise<void> => {
let elapsed : number = 0
// 打开“打印中”弹窗
while (pollCancelled == false && elapsed < MAX_POLL_TIME_MS) {
const queryRes : ResponseData = await queryStatus({ order_id: order_id }) as ResponseData
console.log('queryStatus result', queryRes)
if (queryRes.code != 0) {
throw new Error(queryRes.message)
}
const dataObj = queryRes.data as UTSJSONObject
// 从嵌套的 status 对象中获取 value
const statusObj = dataObj.getJSON('status') as UTSJSONObject | null
const currentStatus : string = statusObj != null ? (statusObj.getString('value') ?? '') : ''
console.log('statusValue', currentStatus)
// 更新状态值,触发图片变化
statusValue.value = currentStatus
console.log('statusValue updated to:', statusValue.value)
// 根据返回值切换UI与逻辑
if (currentStatus == 'waiting' || currentStatus == 'printing') {
// 等待/打印中:维持打印中弹窗显示
} else if (currentStatus == 'completed') {
// 完成:关闭打印中,显示完成弹窗,停止轮询
pollCancelled = true
return
} else if (currentStatus == 'canceled') {
pollCancelled = true
popupTitle.value = '提示'
popupContent.value = '打印已取消'
showPopup.value = true
console.log('打印已取消,弹窗状态:', showPopup.value)
return
} else {
}
await delay(POLL_INTERVAL_MS)
elapsed = elapsed + POLL_INTERVAL_MS
}
throw new Error('打印超时,请稍后重试')
}
const handleBack = () => {
// 用户主动返回,清除所有定时器
cleanupAllTimers()
uni.reLaunch({
url: '/pages/list/index'
})
}
// 打印确认处理函数(基于传入的 order_id 执行打印并轮询)
const handlePrint = async () => {
try {
const printRes : ResponseData = await print({ order_id: orderId.value }) as ResponseData
console.log('print result', printRes)
if (printRes.code != 0) {
throw new Error(printRes.message)
}
// 轮询查询订单状态(waiting/printing -> completed/canceled)
await pollOrderStatus(orderId.value)
} catch (e) {
console.log('打印错误:', e)
popupTitle.value = '提示'
const errorMsg = (e as Error).message
console.log('错误消息:', errorMsg)
popupContent.value = errorMsg.length > 0 ? errorMsg : '打印失败,请联系管理员'
showPopup.value = true
console.log('弹窗状态:', showPopup.value, '内容:', popupContent.value)
}
}
const initDeviceInfo = () => {
const systemInfo = uni.getSystemInfoSync()
screenWidth.value = systemInfo.screenWidth as number
console.log('[CroppImage] 设备信息初始化完成:', systemInfo.screenWidth)
// 获取图片的实际宽高
if (imageSrc.value.length > 0) {
uni.getImageInfo({
src: imageSrc.value,
success: (res) => {
imageWidth.value = res.width
imageHeight.value = res.height
console.log('[printlist] 图片尺寸:', res.width, res.height)
},
fail: (err) => {
console.error('[printlist] 获取图片信息失败:', err)
}
})
}
}
const infoStyle = computed(() => {
if (imageWidth.value == 0 || imageHeight.value == 0) {
return {
width: '0px',
height: '0px'
}
}
const newsizewidth = (screenWidth.value / imageWidth.value) * imageWidth.value * 0.51
const aspectRatio = imageHeight.value / imageWidth.value
const newsizeheight = newsizewidth * aspectRatio
return {
width: `${newsizewidth}px`,
height: `${newsizeheight}px`
}
})
// 页面卸载时,清理所有定时器并停止轮询
onUnload(() : void => {
cleanupAllTimers()
})
onLoad((event : OnLoadOptions) => {
const workStr : UTSJSONObject = JSON.parseObject(event["work"] ?? '{}') as UTSJSONObject
console.log('workStr', workStr)
// 解析并赋值数据
imageSrc.value = workStr.getString('image') ?? ''
worksId.value = workStr.getNumber('works_id') ?? 0
orderId.value = workStr.getNumber('order_id') ?? 0
const goods : UTSJSONObject | null = workStr.getJSON('goods')
if (goods != null) {
goods_name.value = goods.getString('name') ?? ''
goods_front_image.value = goods.getString('front_image') ?? ''
goods_design_color.value = goods.getString('design_color') ?? ''
}
initDeviceInfo()
handlePrint()
// 启动无操作定时器
resetTimer()
})
</script>
<style>
.camera-page {
height: 100%;
}
.back-header {
padding: 0 38rpx;
padding-top: 33rpx;
position: relative;
z-index: 2;
}
.btn-back {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.title-line {
font-weight: bold;
font-size: 25rpx;
color: #666666;
}
.back-btn {
width: 50rpx;
height: 50rpx;
border-radius: 25rpx;
background-color: rgba(255, 255, 255, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.back-icon {
width: 45rpx;
height: 45rpx;
}
.card {
position: relative;
height: 70%;
margin-top: 30rpx;
border-radius: 53rpx;
display: flex;
align-items: center;
justify-content: center;
}
.goods_image {
position: absolute;
z-index: 999;
}
.loading {
width: 100rpx;
height: 90rpx;
}
.printing {
font-size: 28rpx;
color: #fd3da0;
text-align: center;
}
.prompt {
font-size: 22rpx;
color: #666666;
text-align: center;
margin: 20rpx 0;
}
.card-content {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.cropping-area {
margin-top: 50rpx;
}
.actions {
position: fixed;
bottom: 60rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.btn {
width: 180rpx;
height: 80rpx;
border-radius: 16rpx;
font-size: 28rpx;
}
.primary {
width: 181rpx;
height: 83rpx;
background: #FFFFFF;
border-radius: 14rpx;
border: 2rpx solid #FF86C3;
font-weight: bold;
font-size: 24rpx;
color: #000000;
margin-right: 20rpx;
line-height: 83rpx;
}
.pickprimary {
margin-top: 51rpx;
width: 657rpx;
height: 97rpx;
background: #FD3DA0;
border-radius: 14rpx;
font-family: Source Han Sans CN;
font-weight: bold;
font-size: 24rpx;
color: #FFFFFF;
border: none;
line-height: 97rpx;
}
.take-photo-btn {
width: 657rpx;
height: 97rpx;
background: #FD3DA0;
border-radius: 14rpx;
flex-direction: row;
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin: 0 auto;
margin-top: 51rpx;
}
.take-photo-icon {
margin-right: 20rpx;
width: 29.86rpx;
height: 27.08rpx;
}
.take-photo-text {
font-weight: bold;
font-size: 28rpx;
color: #FFFFFF;
}
</style>
\ No newline at end of file
<template>
<!-- 页面容器使用flex垂直布局,简单静态内容无需滚动容器 -->
<view class="page">
<!-- <view class="back-btn" hover-class="hover_btn" @click="handleBack">
<image class="back-icon" src="/static/icon_back.png" mode="aspectFill"></image>
</view> -->
<view class="content">
<image class="icon" src="/static/icon_guaqi.png" mode="aspectFill"></image>
<!-- <text class="title">设备挂起</text> -->
<text class="desc">请稍等,我很快恢复...</text>
</view>
<!-- 底部联系方式,绝对定位固定在页面底部 -->
<view class="footer">
<!-- <text class="contact">请联系管理员</text> -->
<view class="refresh-btn" hover-class="hover_btn" @click="handlerefresh">刷新</view>
</view>
</view>
</template>
<script setup lang="uts">
import { getMachineApp } from '../../utils/reques.uts'
import type { ResponseData } from '../../utils/reques.uts'
// 1分钟倒计时,结束后自动返回首页
// 严格类型:定时器id为 number,可为空
let countdownTimer: number | null = null
let remainingSeconds: number = 60
// 页面加载时启动倒计时
onLoad((): void => {
remainingSeconds = 60
// 每秒减少一次,直到为0
countdownTimer = setInterval(() => {
remainingSeconds = remainingSeconds - 1
if (remainingSeconds <= 0) {
// 结束倒计时并跳转首页
if (countdownTimer != null) {
clearInterval(countdownTimer as number)
countdownTimer = null
}
uni.reLaunch({
url: '/pages/index/index'
})
}
}, 1000) as number
})
// 页面卸载时清理定时器,避免内存泄漏
onUnload((): void => {
if (countdownTimer != null) {
clearInterval(countdownTimer as number)
countdownTimer = null
}
})
const getMachineinfo = async () : Promise<void> => {
const params = {} as object
try {
const res : ResponseData = await getMachineApp(params) as ResponseData
if (res.code == 0) {
uni.reLaunch({
url: '/pages/index/index'
})
}
} catch (error) {
console.log('获取机器信息失败:', error)
}
}
const handlerefresh = () => {
getMachineinfo()
}
</script>
<style>
.page {
/* 页面背景与布局 */
flex: 1;
background-color: #F7F7F7;
align-items: center;
}
.back-btn {
position: absolute;
left: 50rpx;
top: 50rpx;
width: 50rpx;
height: 50rpx;
border-radius: 25rpx;
background-color: rgba(255, 255, 255, 0.5);
display: flex;
justify-content: center;
align-items: center;
}
.back-icon {
width: 45rpx;
height: 45rpx;
}
.content {
/* 通过 flex 垂直居中内容,不使用 margin 顶起 */
flex: 1;
justify-content: center;
align-items: center;
}
.icon {
width: 426rpx;
height: 426rpx;
}
.title {
font-size: 48rpx;
color: #000000;
margin-top: 28rpx;
}
.desc {
font-size: 42rpx;
color: #000000;
margin-top: 12px;
}
.footer {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 100rpx;
align-items: center;
}
.refresh-btn {
width: 160rpx;
height: 80rpx;
border-radius: 40rpx 0 40rpx 0;
color: #000000ff;
border: 2rpx solid #FF86C3;
font-size: 42rpx;
display: flex;
justify-content: center;
align-items: center;
}
.contact {
font-size: 30rpx;
color: #000000;
}
</style>
\ No newline at end of file
// 参考链接 https://doc.dcloud.net.cn/uni-app-x/tutorial/ls-plugin.html#setting
{
"targets": [
"APP-ANDROID"
]
}
\ No newline at end of file
// 全局数据类型定义
// 定义设备数据类型
export type machineItems = {
value : Number
label : string
}
// 定义产品分类数据类型
export type categoryItem = {
value : string
label : string
default_icon : string
}
export type LanguageOption = {
label : string
value : string
}
export type AdsOption = {
image : string
}
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color:#333;//基本色
$uni-text-color-inverse:#fff;//反色
$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable:#c0c0c0;
/* 背景颜色 */
$uni-bg-color:#ffffff;
$uni-bg-color-grey:#f8f8f8;
$uni-bg-color-hover:#f1f1f1;//点击状态颜色
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
/* 边框颜色 */
$uni-border-color:#c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm:12px;
$uni-font-size-base:14px;
$uni-font-size-lg:16px;
/* 图片尺寸 */
$uni-img-size-sm:20px;
$uni-img-size-base:26px;
$uni-img-size-lg:40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2C405A; // 文章标题颜色
$uni-font-size-title:20px;
$uni-color-subtitle: #555555; // 二级标题颜色
$uni-font-size-subtitle:26px;
$uni-color-paragraph: #3F536E; // 文章段落颜色
$uni-font-size-paragraph:15px;
## 1.0.0(2025-01-19)
初始版本
{
"id": "zws-deviceInfo",
"displayName": "zws-deviceInfo",
"version": "1.0.0",
"description": "zws-deviceInfo",
"keywords": [
"zws-deviceInfo"
],
"repository": "",
"engines": {
"HBuilderX": "^3.6.8"
},
"dcloudext": {
"type": "uts",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": "3973360713"
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "y"
},
"client": {
"Vue": {
"vue2": "u",
"vue3": "u"
},
"App": {
"app-android": "y",
"app-ios": "u",
"app-harmony": "u"
},
"H5-mobile": {
"Safari": "u",
"Android Browser": "u",
"微信浏览器(Android)": "u",
"QQ浏览器(Android)": "u"
},
"H5-pc": {
"Chrome": "u",
"IE": "u",
"Edge": "u",
"Firefox": "u",
"Safari": "u"
},
"小程序": {
"微信": "u",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}
\ No newline at end of file
# zws-deviceInfo
### 开发文档
> 引入zws-encrypt
```
import { getDevicesInfo } from "@/uni_modules/zws-deviceInfo"
```
> 方法调用示例
```
getDevicesInfo().ANDROID_ID
```
> 方法说明表
| 方法名 | 作用 |
|-------------------------------|--------------|
| getDevicesInfo().ANDROID_ID | 安卓ID(可用做设备唯一标识) |
| getDevicesInfo().MANUFACTURER | 制造商 |
| getDevicesInfo().MODEL | 设备型号 |
| getDevicesInfo().BOARD | 主板名称 |
| getDevicesInfo().PRODUCT | 产品名称 |
| getDevicesInfo().BRAND | 设备品牌 |
| getDevicesInfo().HOST | 执行代码编译的HOST值 |
| getDevicesInfo().SDK_INT | 安卓SDK代码 |
| getDevicesInfo().RELEASE | 安卓系统版本号 |
| getDevicesInfo().SERIAL | 序列号(已被弃用) |
| getDevicesInfo().HARDWARE | 硬件信息 |
| getDevicesInfo().FINGERPRINT | 设备指纹 |
\ No newline at end of file
{
"minSdkVersion": "21"
}
\ No newline at end of file
import Build from "android.os.Build"
import Settings from "android.provider.Settings"
import { UTSAndroid } from "io.dcloud.uts";
type DevicesInfo = {
ANDROID_ID : String,
MANUFACTURER : String,
MODEL : String,
BOARD : String,
PRODUCT : String,
BRAND : String,
HOST : String,
SDK_INT : Int,
RELEASE : String,
SERIAL : String,
HARDWARE : String,
FINGERPRINT : String
}
export function getDevicesInfo() : DevicesInfo {
const context = UTSAndroid.getAppContext();
let androidId = "";
const tempAndroidId = Settings.Secure.getString(context?.contentResolver, Settings.Secure.ANDROID_ID);
if (tempAndroidId != null && tempAndroidId !== "") {
androidId = tempAndroidId;
}
//3. 兼容处理 SERIAL(低版本兼容 + 高版本返回空)
let serial = "";
if (Build.VERSION.SDK_INT < 26) {
//@ts-ignore 抑制低版本废弃警告
const tempSerial = Build.SERIAL;
if (tempSerial != null && tempSerial !== "") {
serial = tempSerial;
}
}
return {
ANDROID_ID: androidId != null ? androidId : "unknown",
MANUFACTURER: Build.MANUFACTURER != null ? Build.MANUFACTURER : "unknown",
MODEL: Build.MODEL != null ? Build.MODEL : "unknown",
BOARD: Build.BOARD != null ? Build.BOARD : "unknown",
PRODUCT: Build.PRODUCT != null ? Build.PRODUCT : "unknown",
BRAND: Build.BRAND != null ? Build.BRAND : "unknown",
HOST: Build.HOST != null ? Build.HOST : "unknown",
SDK_INT: Build.VERSION.SDK_INT,
RELEASE: Build.VERSION.RELEASE != null ? Build.VERSION.RELEASE : "unknown",
SERIAL: serial != null ? serial : "unknown",
HARDWARE: Build.HARDWARE != null ? Build.HARDWARE : "unknown",
FINGERPRINT: Build.FINGERPRINT != null ? Build.FINGERPRINT : "unknown"
}
/*const res:DevicesInfo={
ANDROID_ID:Settings.Secure.getString(context?.contentResolver,Settings.Secure.ANDROID_ID),
MANUFACTURER:Build.MANUFACTURER,
MODEL:Build.MODEL,
BOARD:Build.BOARD,
PRODUCT:Build.PRODUCT,
BRAND:Build.BRAND,
HOST:Build.HOST,
SDK_INT:Build.VERSION.SDK_INT,
RELEASE:Build.VERSION.RELEASE,
SERIAL:Build.SERIAL,
HARDWARE:Build.HARDWARE,
FINGERPRINT:Build.FINGERPRINT
};
return res; */
// 3. 构建设备信息(统一空值兜底)
\ No newline at end of file
/**
* 引用 iOS 系统库,示例如下:
* import { UIDevice } from "UIKit";
* [可选实现,按需引入]
*/
/* 引入 interface.uts 文件中定义的变量 */
import { MyApiOptions, MyApiResult, MyApi, MyApiSync } from '../interface.uts';
/* 引入 unierror.uts 文件中定义的变量 */
import { MyApiFailImpl } from '../unierror';
/**
* 引入三方库
* [可选实现,按需引入]
*
* 在 iOS 平台引入三方库有以下两种方式:
* 1、通过引入三方库framework 或者.a 等方式,需要将 .framework 放到 ./Frameworks 目录下,将.a 放到 ./Libs 目录下。更多信息[详见](https://uniapp.dcloud.net.cn/plugin/uts-plugin.html#ios-平台原生配置)
* 2、通过 cocoaPods 方式引入,将要引入的 pod 信息配置到 config.json 文件下的 dependencies-pods 字段下。详细配置方式[详见](https://uniapp.dcloud.net.cn/plugin/uts-ios-cocoapods.html)
*
* 在通过上述任意方式依赖三方库后,使用时需要在文件中 import:
* 示例:import { LottieLoopMode } from 'Lottie'
*/
/**
* UTSiOS 为平台内置对象,不需要 import 可直接调用其API,[详见](https://uniapp.dcloud.net.cn/uts/utsios.html)
*/
/**
* 异步方法
*
* uni-app项目中(vue/nvue)调用示例:
* 1、引入方法声明 import { myApi } from "@/uni_modules/uts-api"
* 2、方法调用
* myApi({
* paramA: false,
* complete: (res) => {
* console.log(res)
* }
* });
*
*/
export const myApi : MyApi = function (options : MyApiOptions) {
if (options.paramA == true) {
// 返回数据
const res : MyApiResult = {
fieldA: 85,
fieldB: true,
fieldC: 'some message'
};
options.success?.(res);
options.complete?.(res);
} else {
// 返回错误
let failResult = new MyApiFailImpl(9010001);
options.fail?.(failResult)
options.complete?.(failResult)
}
}
/**
* 同步方法
*
* uni-app项目中(vue/nvue)调用示例:
* 1、引入方法声明 import { myApiSync } from "@/uni_modules/uts-api"
* 2、方法调用
* myApiSync(true);
*
*/
export const myApiSync : MyApiSync = function (paramA : boolean) : MyApiResult {
// 返回数据,根据插件功能获取实际的返回值
const res : MyApiResult = {
fieldA: 85,
fieldB: paramA,
fieldC: 'some message'
};
return res;
}
/**
* 更多插件开发的信息详见:https://uniapp.dcloud.net.cn/plugin/uts-plugin.html
*/
// const apiurl = `http://192.168.31.147:3001/ai-camera`
// const apiurl = `http://test.refinecolor.com/ai-camera`
const apiurl = `http://ai.colorpark.cn/ai-camera`
// const apiurl = `http://tprint-dev.refinecolor.com/ai-camera`
type ApiEndpoints = {
binds: string
languages: string
list: string
category: string
render: string
queryRenderResult: string
goodscategory: string
ossUpload:string
creatework:string
getmachines:string
getgoods:string
uploadQrcode:string
uploadurl:string
getMachineApp:string
guestLogin:string
print:string
createorder:string
queryStatus:string
cancel:string
queueList:string
ads:string
shareQrcode:string
}
const api: ApiEndpoints = {
binds: `${apiurl}/index/binds`,
languages: `${apiurl}/index/languages`,
list: `${apiurl}/aiModel/list`,
category: `${apiurl}/aiModel/category`,
render: `${apiurl}/aiModel/render`,
queryRenderResult: `${apiurl}/aiModel/queryRenderResult/`,
goodscategory: `${apiurl}/index/category`,
ossUpload: `${apiurl}/index/ossUpload`,
creatework: `${apiurl}/works/create`,
getmachines: `${apiurl}/index/machines`,
getgoods: `${apiurl}/index/goods`,
uploadQrcode: `${apiurl}/index/uploadQrcode`,
uploadurl: `${apiurl}/aiModel/render`,
getMachineApp: `${apiurl}/index/getMachineApp`,
guestLogin: `${apiurl}/index/guestLogin`,
print: `${apiurl}/aiModel/print`,
createorder: `${apiurl}/order/create`,
queryStatus: `${apiurl}/order/queryStatus/`,
cancel: `${apiurl}/aiModel/cancel/`,
queueList: `${apiurl}/index/queueList`,
ads: `${apiurl}/index/ads`,
shareQrcode: `${apiurl}/index/shareQrcode`,
}
export default api
\ No newline at end of file
import api from "./api.uts"
// 定义并导出响应数据类型
export type ResponseData = {
code : number
message : string
// data/meta 可能为对象或数组,保持为 any,由调用方按需转换
data ?: any | null
meta ?: any | null
}
// 缓存获取函数,确保每次都获取最新的缓存值
const getAuthHeaders = () : UTSJSONObject => {
const machine_code = uni.getStorageSync('machine_code')
const guest_Token = uni.getStorageSync('guestToken') ?? ''
const device_id = uni.getStorageSync('device_id') ?? ''
return {
'machine-code': machine_code,
'Authorization': `Guest ${guest_Token}`,
'device-id': device_id,
} as UTSJSONObject
}
// 显示Toast后延迟跳转,避免立即reLaunch导致Toast不显示
function showToastThenReLaunch(msg : string, url : string, duration : number = 4500) : void {
// 先关闭可能存在的Loading遮罩,确保Toast可见
uni.showToast({
title: msg,
icon: 'none',
duration: duration
})
// 延迟跳转,等待Toast展示完成
setTimeout(() : void => {
uni.reLaunch({
url: url
})
}, duration)
}
const request = (url : string, method : string, data : any) => {
return new Promise((resolve, reject) => {
uni.request({
url: url,
method: method as RequestMethod,
timeout: 15000,
data: data,
header: getAuthHeaders(),
success: (res) => {
// 直接使用 res.data,不进行类型转换
const responseData = res.data as UTSJSONObject
const code = responseData.getNumber('code') ?? 0
const message = responseData.getString('message') ?? ''
const dataField = responseData.getAny('data')
const meta = responseData.getAny('meta')
if ((code as number) == 0) {
const result : ResponseData = {
code: (code as number),
message: message,
data: dataField != null ? dataField : null,
meta: meta != null ? meta : null,
}
resolve(result)
} else {
console.log('message', message)
if ((code as number) == 130001) {
showToastThenReLaunch(message, '/pages/stop/index', 4500)
return;
}
// 处理错误码130002 - 清除machine_code并跳转首页
if ((code as number) == 130002) {
uni.removeStorage({
key: 'machine_code',
success: function (res) {
console.log('success,清除machine_code并跳转首页');
}
});
// 先显示提示,再延迟跳转,避免Toast未显示
showToastThenReLaunch(message, '/pages/index/index', 4500)
return;
}
// 对于其他错误码,reject promise 以便调用方的 catch 块能够处理
reject({ code: code, message: message })
}
},
fail: (error) => {
uni.showToast({
title: `网络错误`,
icon: 'error'
})
reject(error)
}
})
})
}
const ossUpload = (
filePath : string
) => {
return new Promise((resolve, reject) => {
uni.showLoading({
title: '上传中...',
mask: true
})
uni.uploadFile({
url: api.ossUpload as string,
filePath: filePath,
name: 'file',
timeout: 15000,
header: getAuthHeaders(),
success(res) {
try {
const dataStr = (res.data as string) ?? ''
if (dataStr.length == 0) {
const emptyResult : ResponseData = { code: 0, message: '', data: null }
resolve(emptyResult)
return
}
const responseJSON = JSON.parse(dataStr) as UTSJSONObject
const code = responseJSON.getNumber('code') ?? 0
const message = responseJSON.getString('message') ?? ''
const dataField = responseJSON.getAny('data')
// 处理错误码130002 - 清除machine_code并跳转首页
if (code == 130002) {
uni.removeStorage({
key: 'machine_code',
success: function (res) {
console.log('success');
}
});
// 跳转到首页
uni.reLaunch({
url: '/pages/index/index'
});
return;
}
const result : ResponseData = {
code: (code as number),
message: message,
data: dataField != null ? (dataField as UTSJSONObject) : null
}
uni.hideLoading()
resolve(result)
} catch (e) {
uni.hideLoading()
uni.showToast({
title: '响应解析失败',
icon: 'error'
})
reject(e as any)
}
},
fail(error_1) {
uni.hideLoading()
uni.showToast({
title: '网络错误',
icon: 'error'
})
reject(error_1)
}
})
})
}
const binds = (params : object) => {
return request(api.binds as string, 'POST', params)
}
const guestLogin = (params : object) => {
return request(api.guestLogin as string, 'POST', params)
}
const languages = (params : object) => {
return request(api.languages as string, 'POST', params)
}
const list = (params : object) => {
return request(api.list as string, 'POST', params)
}
const category = (params : object) => {
return request(api.category as string, 'POST', params)
}
const queryRenderResult = (prompt_id : string) => {
return request(api.queryRenderResult + prompt_id as string, 'GET', {})
}
const goodscategory = (params : object) => {
return request(api.goodscategory as string, 'POST', params)
}
const creatework = (params : object) => {
return request(api.creatework as string, 'POST', params)
}
const getmachines = (params : object) => {
return request(api.getmachines as string, 'POST', params)
}
const getgoods = (params : object) => {
return request(api.getgoods as string, 'POST', params)
}
const uploadQrcode = (fd : string) => {
return request(api.uploadQrcode + fd as string, 'POST', {})
}
const uploadurl = (params : object) => {
return request(api.uploadurl as string, 'POST', params)
}
const getMachineApp = (params : object) => {
return request(api.getMachineApp as string, 'POST', params)
}
const print = (params : object) => {
return request(api.print as string, 'POST', params)
}
const createorder = (params : object) => {
return request(api.createorder as string, 'POST', params)
}
const queryStatus = (params : object) => {
return request(api.queryStatus as string, 'POST', params)
}
const cancel = (params : object) => {
return request(api.cancel as string, 'POST', params)
}
const queueList = (params : object) => {
return request(api.queueList as string, 'POST', params)
}
const ads = (params : object) => {
return request(api.ads as string, 'POST', params)
}
const shareQrcode = (works_id: number) => {
return request(api.shareQrcode + '?works_id=' + works_id.toString(), 'GET', {})
}
export { binds, guestLogin, list, category, languages, queryRenderResult, uploadurl, ossUpload, goodscategory, creatework, getmachines, getgoods, uploadQrcode, getMachineApp, print, createorder, queryStatus, cancel, queueList, ads, shareQrcode}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment