face-photo.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. <template>
  2. <view class="photo-container" :class="{'loaded': isPageLoaded}">
  3. <!-- 标题 -->
  4. <view class="photo-header">
  5. <text class="photo-title">拍摄面部照片</text>
  6. <text class="photo-subtitle">我们将用于身份核验,请正对摄像头</text>
  7. </view>
  8. <!-- 模式选择 -->
  9. <view class="mode-selector">
  10. <view class="mode-option" :class="{'active': mode === 'photo'}" @click="switchMode('photo')">拍照</view>
  11. <view class="mode-option" :class="{'active': mode === 'video'}" @click="switchMode('video')">录制视频</view>
  12. </view>
  13. <!-- 照片/视频预览区域 -->
  14. <view class="photo-preview">
  15. <!-- 使用camera组件替代静态图片 -->
  16. <camera v-if="!mediaSource" device-position="front" flash="auto" class="camera"
  17. :mode="mode" @error="handleCameraError"></camera>
  18. <image v-else-if="mode === 'photo'" class="preview-image" :src="mediaSource" mode="aspectFit"></image>
  19. <video v-else-if="mode === 'video'" class="preview-video" :src="mediaSource"
  20. controls autoplay></video>
  21. <view class="face-outline"></view>
  22. <!-- 视频录制时间显示 -->
  23. <view v-if="mode === 'video' && isRecording" class="recording-indicator">
  24. <view class="recording-dot"></view>
  25. <text class="recording-time">{{formatTime(recordingTime)}}</text>
  26. </view>
  27. </view>
  28. <!-- 操作按钮 -->
  29. <view v-if="!mediaSource" class="capture-btn-container">
  30. <button v-if="mode === 'photo'" class="capture-btn" @click="takePhoto">拍照</button>
  31. <button v-else-if="mode === 'video' && !isRecording" class="capture-btn" @click="startRecording">开始录制</button>
  32. <button v-else-if="mode === 'video' && isRecording" class="stop-btn" @click="stopRecording">停止录制</button>
  33. </view>
  34. <view v-else class="btn-group">
  35. <button class="retry-btn" @click="retakeMedia">重新{{mode === 'photo' ? '拍照' : '录制'}}</button>
  36. <button class="start-btn" @click="continueProcess">完成</button>
  37. </view>
  38. </view>
  39. </template>
  40. <script>
  41. import { uploadPhoto,fillUserInfo,getUserInfo } from '@/api/user';
  42. export default {
  43. data() {
  44. return {
  45. mediaSource: '', // 拍摄的照片或视频路径
  46. isCameraReady: false,
  47. cameraContext: null,
  48. isPageLoaded: false, // 添加页面加载状态标记
  49. mode: 'photo', // 默认为拍照模式,可选值:photo, video
  50. isRecording: false, // 是否正在录制视频
  51. recordingTime: 0, // 录制时间(秒)
  52. recordingTimer: null, // 录制计时器
  53. photoUrl: '', // 存储上传后返回的图片URL
  54. }
  55. },
  56. onReady() {
  57. // 相机组件初始化完成后创建相机上下文
  58. this.cameraContext = uni.createCameraContext();
  59. // 设置页面已加载完成
  60. setTimeout(() => {
  61. this.isPageLoaded = true;
  62. }, 100);
  63. },
  64. methods: {
  65. // 切换拍照/录制模式
  66. switchMode(newMode) {
  67. if (this.mediaSource) {
  68. // 如果已经有拍摄内容,需要先清除
  69. this.retakeMedia();
  70. }
  71. this.mode = newMode;
  72. },
  73. // 处理相机错误
  74. handleCameraError(e) {
  75. console.error('相机错误:', e);
  76. uni.showToast({
  77. title: '相机初始化失败,请检查权限设置',
  78. icon: 'none'
  79. });
  80. },
  81. // 拍照方法
  82. takePhoto() {
  83. if (!this.cameraContext) {
  84. uni.showToast({
  85. title: '相机未就绪',
  86. icon: 'none'
  87. });
  88. return;
  89. }
  90. uni.showLoading({
  91. title: '拍照中...'
  92. });
  93. this.cameraContext.takePhoto({
  94. quality: 'high',
  95. success: (res) => {
  96. this.mediaSource = res.tempImagePath;
  97. uni.hideLoading();
  98. },
  99. fail: (err) => {
  100. console.error('拍照失败:', err);
  101. uni.hideLoading();
  102. uni.showToast({
  103. title: '拍照失败,请重试',
  104. icon: 'none'
  105. });
  106. }
  107. });
  108. },
  109. // 开始录制视频
  110. startRecording() {
  111. if (!this.cameraContext) {
  112. uni.showToast({
  113. title: '相机未就绪',
  114. icon: 'none'
  115. });
  116. return;
  117. }
  118. this.isRecording = true;
  119. this.recordingTime = 0;
  120. // 开始计时
  121. this.recordingTimer = setInterval(() => {
  122. this.recordingTime++;
  123. // 限制最长录制时间为60秒
  124. if (this.recordingTime >= 60) {
  125. this.stopRecording();
  126. }
  127. }, 1000);
  128. this.cameraContext.startRecord({
  129. success: () => {
  130. console.log('开始录制视频');
  131. },
  132. fail: (err) => {
  133. console.error('开始录制失败:', err);
  134. this.isRecording = false;
  135. clearInterval(this.recordingTimer);
  136. uni.showToast({
  137. title: '录制失败,请重试',
  138. icon: 'none'
  139. });
  140. }
  141. });
  142. },
  143. // 停止录制视频
  144. stopRecording() {
  145. if (!this.isRecording) return;
  146. clearInterval(this.recordingTimer);
  147. this.isRecording = false;
  148. uni.showLoading({
  149. title: '处理中...'
  150. });
  151. this.cameraContext.stopRecord({
  152. success: (res) => {
  153. this.mediaSource = res.tempVideoPath;
  154. console.log(res);
  155. uni.hideLoading();
  156. },
  157. fail: (err) => {
  158. console.error('停止录制失败:', err);
  159. uni.hideLoading();
  160. uni.showToast({
  161. title: '视频保存失败,请重试',
  162. icon: 'none'
  163. });
  164. }
  165. });
  166. },
  167. // 格式化时间显示 (秒 -> MM:SS)
  168. formatTime(seconds) {
  169. const minutes = Math.floor(seconds / 60);
  170. const remainingSeconds = seconds % 60;
  171. return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
  172. },
  173. // 重新拍照或录制
  174. retakeMedia() {
  175. this.mediaSource = '';
  176. this.recordingTime = 0;
  177. },
  178. // 继续流程
  179. continueProcess() {
  180. if (!this.mediaSource) {
  181. uni.showToast({
  182. title: '请先完成拍照',
  183. icon: 'none'
  184. });
  185. return;
  186. }
  187. uni.showLoading({
  188. title: '上传中...'
  189. });
  190. // 上传当前照片
  191. this.uploadMedia((url) => {
  192. // 保存照片URL
  193. this.photoUrl = url;
  194. // 直接提交照片URL到指定接口
  195. this.submitPhotoUrl();
  196. });
  197. },
  198. // 上传媒体文件方法
  199. uploadMedia(callback) {
  200. // 获取openid和tenant_id,可以从缓存或全局状态获取
  201. const openid = JSON.parse(uni.getStorageSync('userInfo')).openid || '';
  202. const tenant_id = 1 || '';
  203. if (!openid || !tenant_id) {
  204. uni.hideLoading();
  205. uni.showToast({
  206. title: '用户信息不完整,请重新登录',
  207. icon: 'none'
  208. });
  209. return;
  210. }
  211. // 使用uni.uploadFile方式上传图片
  212. uni.uploadFile({
  213. url: 'https://minlong.raycos.com.cn/api/system/upload/',
  214. filePath: this.mediaSource,
  215. name: 'file',
  216. formData: {
  217. openid: openid,
  218. tenant_id: tenant_id
  219. },
  220. success: (uploadRes) => {
  221. // 解析返回的JSON字符串
  222. const res = JSON.parse(uploadRes.data);
  223. if (res.code === 2000) { // 根据实际API响应调整
  224. // 获取返回的图片URL
  225. const photoUrl = res.data.url || res.data.photoUrl || '';
  226. if (callback && typeof callback === 'function') {
  227. callback(photoUrl);
  228. }
  229. } else {
  230. uni.hideLoading();
  231. uni.showToast({
  232. title: res.msg || '照片上传失败',
  233. icon: 'none'
  234. });
  235. }
  236. },
  237. fail: (err) => {
  238. uni.hideLoading();
  239. console.error('上传失败:', err);
  240. uni.showToast({
  241. title: '网络错误,请重试',
  242. icon: 'none'
  243. });
  244. }
  245. });
  246. },
  247. // 提交照片URL到指定接口
  248. submitPhotoUrl() {
  249. const openid = JSON.parse(uni.getStorageSync('userInfo')).openid || '';
  250. const tenant_id = 1 || '';
  251. if (!this.photoUrl) {
  252. uni.hideLoading();
  253. uni.showToast({
  254. title: '照片信息不完整,请重试',
  255. icon: 'none'
  256. });
  257. return;
  258. }
  259. // 使用fillUserInfo方法进行上传
  260. fillUserInfo({
  261. application_id: JSON.parse(uni.getStorageSync('selectedJob')).id,
  262. openid: openid,
  263. tenant_id: tenant_id,
  264. avatar: this.photoUrl,
  265. }).then(res => {
  266. uni.hideLoading();
  267. console.log(res);
  268. // this.updateLocalUserInfo();
  269. uni.showToast({
  270. title: '照片上传成功',
  271. icon: 'success'
  272. });
  273. // 上传成功后跳转到下一页
  274. setTimeout(() => {
  275. uni.navigateTo({
  276. url: '/pages/identity-verify/identity-verify',
  277. fail: (err) => {
  278. console.error('页面跳转失败:', err);
  279. uni.showToast({
  280. title: '页面跳转失败',
  281. icon: 'none'
  282. });
  283. }
  284. });
  285. }, 1500);
  286. }).catch(err => {
  287. uni.hideLoading();
  288. console.error('提交失败:', err);
  289. uni.showToast({
  290. title: '网络错误,请重试',
  291. icon: 'none'
  292. });
  293. });
  294. },
  295. updateLocalUserInfo() {
  296. getUserInfo()
  297. .then(res => {
  298. if (res.code === 200 && res.data) {
  299. let userInfo = {};
  300. try {
  301. userInfo = JSON.parse(uni.getStorageSync('userInfo') || '{}');
  302. } catch (e) {
  303. console.error('解析本地存储用户信息失败:', e);
  304. userInfo = {};
  305. }
  306. const updatedUserInfo = {
  307. ...userInfo,
  308. ...res.data
  309. };
  310. uni.setStorageSync('userInfo', JSON.stringify(updatedUserInfo));
  311. }
  312. })
  313. .catch(err => {
  314. console.error('更新本地用户信息失败:', err);
  315. });
  316. }
  317. }
  318. }
  319. </script>
  320. <style>
  321. .photo-container {
  322. display: flex;
  323. flex-direction: column;
  324. min-height: 100vh;
  325. background-color: #f5f7fa;
  326. padding: 30rpx;
  327. opacity: 0; /* 初始设置为不可见 */
  328. transition: opacity 0.3s ease; /* 添加过渡效果 */
  329. }
  330. .photo-container.loaded {
  331. opacity: 1; /* 加载完成后显示 */
  332. }
  333. .photo-header {
  334. display: flex;
  335. flex-direction: column;
  336. align-items: center;
  337. margin-bottom: 20rpx;
  338. }
  339. .photo-title {
  340. font-size: 36rpx;
  341. font-weight: bold;
  342. color: #333;
  343. margin-bottom: 10rpx;
  344. }
  345. .photo-subtitle {
  346. font-size: 28rpx;
  347. color: #666;
  348. }
  349. /* 模式选择器样式 */
  350. .mode-selector {
  351. display: flex;
  352. justify-content: center;
  353. margin-bottom: 30rpx;
  354. background-color: #eaeaea;
  355. border-radius: 40rpx;
  356. padding: 6rpx;
  357. width: 80%;
  358. align-self: center;
  359. }
  360. .mode-option {
  361. flex: 1;
  362. text-align: center;
  363. padding: 16rpx 0;
  364. font-size: 28rpx;
  365. color: #666;
  366. border-radius: 36rpx;
  367. transition: all 0.3s;
  368. }
  369. .mode-option.active {
  370. background-color: #6c5ce7;
  371. color: #fff;
  372. }
  373. .photo-preview {
  374. position: relative;
  375. width: 100%;
  376. height: 800rpx;
  377. background-color: #000;
  378. border-radius: 20rpx;
  379. overflow: hidden;
  380. margin-bottom: 40rpx;
  381. display: flex;
  382. justify-content: center;
  383. align-items: center;
  384. }
  385. .preview-image, .preview-video {
  386. width: 100%;
  387. height: 100%;
  388. object-fit: cover;
  389. }
  390. .face-outline {
  391. position: absolute;
  392. top: 50%;
  393. left: 50%;
  394. transform: translate(-50%, -50%);
  395. width: 400rpx;
  396. height: 500rpx;
  397. border: 4rpx dashed #fff;
  398. border-radius: 200rpx;
  399. pointer-events: none;
  400. }
  401. .camera {
  402. width: 100%;
  403. height: 100%;
  404. }
  405. /* 录制指示器 */
  406. .recording-indicator {
  407. position: absolute;
  408. top: 30rpx;
  409. right: 30rpx;
  410. display: flex;
  411. align-items: center;
  412. background-color: rgba(0, 0, 0, 0.5);
  413. padding: 10rpx 20rpx;
  414. border-radius: 30rpx;
  415. }
  416. .recording-dot {
  417. width: 20rpx;
  418. height: 20rpx;
  419. background-color: #ff0000;
  420. border-radius: 50%;
  421. margin-right: 10rpx;
  422. animation: blink 1s infinite;
  423. }
  424. .recording-time {
  425. color: #fff;
  426. font-size: 28rpx;
  427. }
  428. @keyframes blink {
  429. 0% { opacity: 1; }
  430. 50% { opacity: 0.3; }
  431. 100% { opacity: 1; }
  432. }
  433. .capture-btn-container {
  434. width: 100%;
  435. display: flex;
  436. justify-content: center;
  437. margin-top: 60rpx;
  438. }
  439. .capture-btn {
  440. width: 100%;
  441. height: 90rpx;
  442. line-height: 90rpx;
  443. background-color: #6c5ce7;
  444. color: #fff;
  445. border-radius: 45rpx;
  446. font-size: 32rpx;
  447. }
  448. .stop-btn {
  449. width: 100%;
  450. height: 90rpx;
  451. line-height: 90rpx;
  452. background-color: #e74c3c;
  453. color: #fff;
  454. border-radius: 45rpx;
  455. font-size: 32rpx;
  456. }
  457. .btn-group {
  458. display: flex;
  459. justify-content: space-between;
  460. width: 100%;
  461. margin-top: 60rpx;
  462. }
  463. .retry-btn {
  464. width: 45%;
  465. height: 90rpx;
  466. line-height: 90rpx;
  467. background-color: #fff;
  468. color: #6c5ce7;
  469. border: 1px solid #6c5ce7;
  470. border-radius: 45rpx;
  471. font-size: 32rpx;
  472. }
  473. .start-btn {
  474. width: 45%;
  475. height: 90rpx;
  476. line-height: 90rpx;
  477. background-color: #6c5ce7;
  478. color: #fff;
  479. border-radius: 45rpx;
  480. font-size: 32rpx;
  481. }
  482. /* 添加手部轮廓样式 */
  483. .face-outline.hand-outline {
  484. width: 500rpx;
  485. height: 300rpx;
  486. border-radius: 30rpx;
  487. }
  488. </style>