| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955 |
- <template>
- <view class="page">
- <NavBar title="签到打卡" :showBack="true" />
-
- <view class="content">
- <view class="location-card">
- <view class="location-header">
- <view class="location-icon"></view>
- <text class="location-title">当前位置</text>
- <view class="refresh-btn" @click="refreshLocation">
- <view class="refresh-icon" :class="{ rotating: isLocating }"></view>
- </view>
- </view>
- <view class="location-content" v-if="currentLocation">
- <view class="location-row">
- <text class="location-label">经纬度:</text>
- <text class="location-value">{{ currentLocation.latitude.toFixed(6) }}, {{ currentLocation.longitude.toFixed(6) }}</text>
- </view>
- <view class="location-row">
- <text class="location-label">地址:</text>
- <text class="location-value">{{ currentLocation.address }}</text>
- </view>
- <view class="location-row">
- <text class="location-label">精度:</text>
- <text class="location-value">{{ currentLocation.accuracy }}米</text>
- </view>
- </view>
- <view class="location-loading" v-else>
- <text class="loading-text">{{ locationError || '正在获取位置...' }}</text>
- </view>
- </view>
-
- <view class="type-section">
- <view class="section-header">
- <text class="section-title">签到类型</text>
- </view>
- <view class="type-list">
- <view
- class="type-item"
- :class="{ active: selectedType === CheckinType.INVENTORY_TALLY }"
- @click="selectType(CheckinType.INVENTORY_TALLY)"
- >
- <view class="type-icon inventory">
- <view class="icon-inner"></view>
- </view>
- <view class="type-info">
- <text class="type-name">理货盘点签到</text>
- <text class="type-desc">仓库人员理货盘点确认</text>
- </view>
- <view class="type-check" v-if="selectedType === CheckinType.INVENTORY_TALLY">
- <view class="check-icon"></view>
- </view>
- </view>
-
- <view
- class="type-item"
- :class="{ active: selectedType === CheckinType.DELIVERY_REPLENISH }"
- @click="selectType(CheckinType.DELIVERY_REPLENISH)"
- >
- <view class="type-icon delivery">
- <view class="icon-inner"></view>
- </view>
- <view class="type-info">
- <text class="type-name">补货确认签到</text>
- <text class="type-desc">配送人员补货确认</text>
- </view>
- <view class="type-check" v-if="selectedType === CheckinType.DELIVERY_REPLENISH">
- <view class="check-icon"></view>
- </view>
- </view>
- </view>
- </view>
-
- <view class="photo-section">
- <view class="section-header">
- <text class="section-title">拍照签到</text>
- <text class="section-tip">至少拍摄1张照片</text>
- </view>
-
- <view class="photo-grid">
- <view
- class="photo-item"
- v-for="(photo, index) in photos"
- :key="photo.id"
- >
- <image
- class="photo-image"
- :src="photo.path"
- mode="aspectFill"
- @click="previewPhoto(photo.path)"
- />
- <view class="photo-delete" @click="removePhoto(index)">
- <view class="delete-icon"></view>
- </view>
- <view class="photo-index">{{ index + 1 }}</view>
- </view>
-
- <view class="photo-add" @click="addPhoto" v-if="photos.length < 5">
- <view class="add-icon">
- <view class="add-h"></view>
- <view class="add-v"></view>
- </view>
- <text class="add-text">拍照</text>
- </view>
- </view>
- </view>
-
- <view class="remark-section">
- <view class="section-header">
- <text class="section-title">备注信息</text>
- <text class="section-tip">选填</text>
- </view>
- <textarea
- class="remark-input"
- v-model="remark"
- placeholder="请输入备注信息..."
- :maxlength="200"
- placeholder-class="placeholder"
- />
- <view class="remark-count">
- <text>{{ remark.length }}/200</text>
- </view>
- </view>
-
- <view class="offline-tip" v-if="pendingSyncCount > 0">
- <view class="tip-icon"></view>
- <text class="tip-text">有 {{ pendingSyncCount }} 条离线签到待同步</text>
- <text class="tip-action" @click="syncOfflineRecords">立即同步</text>
- </view>
-
- <view class="submit-section">
- <button
- class="submit-btn"
- :class="{ disabled: !canSubmit }"
- :disabled="!canSubmit || isSubmitting"
- @click="handleSubmit"
- >
- <view class="btn-loading" v-if="isSubmitting"></view>
- <text v-else>{{ isSubmitting ? '提交中...' : '确认签到' }}</text>
- </button>
- </view>
- </view>
-
- <view class="success-modal" v-if="showSuccess">
- <view class="success-content">
- <view class="success-icon">
- <view class="check-mark"></view>
- </view>
- <text class="success-title">签到成功</text>
- <text class="success-time">{{ successTime }}</text>
- <button class="success-btn" @click="handleSuccessClose">确定</button>
- </view>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { ref, computed, onMounted } from 'vue';
- import NavBar from '@/components/NavBar.vue';
- import { CheckinType } from '@/api/checkin';
- import type { LocationInfo, CheckinPhoto } from '@/api/checkin';
- import {
- getCurrentLocation,
- takePhotoWithWatermark,
- saveOfflineCheckin,
- getOfflineCheckins,
- removeOfflineCheckin,
- generateOfflineId,
- checkLocationPermission,
- checkCameraPermission,
- formatWatermarkTime
- } from '@/utils/checkin';
- import { getUserInfo } from '@/utils/auth';
- const selectedType = ref<CheckinType>(CheckinType.INVENTORY_TALLY);
- const currentLocation = ref<LocationInfo | null>(null);
- const locationError = ref('');
- const isLocating = ref(false);
- const photos = ref<CheckinPhoto[]>([]);
- const remark = ref('');
- const isSubmitting = ref(false);
- const showSuccess = ref(false);
- const successTime = ref('');
- const pendingSyncCount = ref(0);
- const canSubmit = computed(() => {
- return currentLocation.value && photos.value.length > 0 && !isSubmitting.value;
- });
- onMounted(() => {
- initLocation();
- updatePendingCount();
- });
- const initLocation = async () => {
- isLocating.value = true;
- locationError.value = '';
-
- try {
- const hasPermission = await checkLocationPermission();
- if (!hasPermission) {
- locationError.value = '请开启定位权限';
- return;
- }
-
- currentLocation.value = await getCurrentLocation();
- } catch (error: any) {
- locationError.value = error.message || '获取位置失败';
- } finally {
- isLocating.value = false;
- }
- };
- const refreshLocation = async () => {
- if (isLocating.value) return;
- await initLocation();
- };
- const selectType = (type: CheckinType) => {
- selectedType.value = type;
- };
- const addPhoto = async () => {
- if (!currentLocation.value) {
- uni.showToast({
- title: '请先获取位置信息',
- icon: 'none'
- });
- return;
- }
-
- const hasPermission = await checkCameraPermission();
- if (!hasPermission) return;
-
- try {
- const photo = await takePhotoWithWatermark(selectedType.value, currentLocation.value);
- photos.value.push(photo);
- } catch (error: any) {
- uni.showToast({
- title: error.message || '拍照失败',
- icon: 'none'
- });
- }
- };
- const removePhoto = (index: number) => {
- photos.value.splice(index, 1);
- };
- const previewPhoto = (path: string) => {
- uni.previewImage({
- urls: photos.value.map(p => p.path),
- current: path
- });
- };
- const updatePendingCount = () => {
- const offlineRecords = getOfflineCheckins();
- pendingSyncCount.value = offlineRecords.filter(r => r.status === 'pending').length;
- };
- const handleSubmit = async () => {
- if (!canSubmit.value || !currentLocation.value) return;
-
- isSubmitting.value = true;
-
- try {
- const userInfo = getUserInfo() || {};
- const offlineId = generateOfflineId();
-
- const params = {
- type: selectedType.value,
- location: currentLocation.value,
- photos: photos.value,
- remark: remark.value,
- offlineId
- };
-
- try {
- const record = await submitCheckin(params);
-
- showSuccess.value = true;
- successTime.value = formatWatermarkTime();
-
- photos.value = [];
- remark.value = '';
-
- updatePendingCount();
- } catch (error) {
- const offlineRecord = {
- id: offlineId,
- type: selectedType.value,
- typeName: selectedType.value === CheckinType.INVENTORY_TALLY ? '理货盘点签到' : '补货确认签到',
- userId: userInfo.id || 1,
- userName: userInfo.nickname || userInfo.username || '未知用户',
- userNo: userInfo.userNo || 'N/A',
- location: currentLocation.value!,
- photos: photos.value,
- remark: remark.value,
- status: 'pending' as const,
- createdAt: new Date().toISOString(),
- offlineId
- };
-
- saveOfflineCheckin(offlineRecord);
-
- uni.showToast({
- title: '已保存到本地,稍后同步',
- icon: 'none'
- });
-
- photos.value = [];
- remark.value = '';
- updatePendingCount();
- }
- } finally {
- isSubmitting.value = false;
- }
- };
- const syncOfflineRecords = async () => {
- const offlineRecords = getOfflineCheckins().filter(r => r.status === 'pending');
-
- if (offlineRecords.length === 0) {
- uni.showToast({
- title: '没有待同步的记录',
- icon: 'none'
- });
- return;
- }
-
- uni.showLoading({ title: '同步中...' });
-
- try {
- const result = await syncOfflineCheckins(offlineRecords);
-
- uni.hideLoading();
-
- if (result.success > 0) {
- uni.showToast({
- title: `成功同步 ${result.success} 条记录`,
- icon: 'success'
- });
-
- offlineRecords.forEach(record => {
- if (record.offlineId) {
- removeOfflineCheckin(record.offlineId);
- }
- });
-
- updatePendingCount();
- }
- } catch (error) {
- uni.hideLoading();
- uni.showToast({
- title: '同步失败,请稍后重试',
- icon: 'none'
- });
- }
- };
- const handleSuccessClose = () => {
- showSuccess.value = false;
- };
- </script>
- <style lang="scss" scoped>
- .page {
- min-height: 100vh;
- background: $bg-color-page;
- }
- .content {
- padding: 24rpx;
- padding-top: 0;
- }
- .location-card {
- background: $bg-color-card;
- border-radius: 20rpx;
- padding: 24rpx;
- margin-bottom: 24rpx;
- border: 1rpx solid $border-color;
- }
- .location-header {
- display: flex;
- align-items: center;
- margin-bottom: 16rpx;
- }
- .location-icon {
- width: 36rpx;
- height: 36rpx;
- background: $primary-color;
- border-radius: 50%;
- margin-right: 12rpx;
- position: relative;
-
- &::before {
- content: '';
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 12rpx;
- height: 12rpx;
- background: $bg-color-card;
- border-radius: 50%;
- }
- }
- .location-title {
- font-size: 28rpx;
- font-weight: 600;
- color: $text-color-primary;
- flex: 1;
- }
- .refresh-btn {
- width: 56rpx;
- height: 56rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- background: $bg-color-secondary;
- border-radius: 50%;
-
- &:active {
- background: $border-color;
- }
- }
- .refresh-icon {
- width: 24rpx;
- height: 24rpx;
- border: 3rpx solid $text-color-tertiary;
- border-radius: 50%;
- border-top-color: transparent;
-
- &.rotating {
- animation: rotate 1s linear infinite;
- }
-
- @keyframes rotate {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
- }
- }
- .location-content {
- padding: 16rpx;
- background: $bg-color-page;
- border-radius: 12rpx;
- }
- .location-row {
- display: flex;
- align-items: flex-start;
- margin-bottom: 12rpx;
-
- &:last-child {
- margin-bottom: 0;
- }
- }
- .location-label {
- font-size: 24rpx;
- color: $text-color-tertiary;
- width: 100rpx;
- flex-shrink: 0;
- }
- .location-value {
- font-size: 24rpx;
- color: $text-color-primary;
- flex: 1;
- }
- .location-loading {
- padding: 24rpx;
- text-align: center;
- }
- .loading-text {
- font-size: 26rpx;
- color: $text-color-tertiary;
- }
- .section-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 16rpx;
- }
- .section-title {
- font-size: 28rpx;
- font-weight: 600;
- color: $text-color-primary;
- }
- .section-tip {
- font-size: 24rpx;
- color: $text-color-muted;
- }
- .type-section {
- margin-bottom: 24rpx;
- }
- .type-list {
- background: $bg-color-card;
- border-radius: 20rpx;
- border: 1rpx solid $border-color;
- overflow: hidden;
- }
- .type-item {
- display: flex;
- align-items: center;
- padding: 28rpx 24rpx;
- border-bottom: 1rpx solid $bg-color-secondary;
- transition: background 0.15s;
-
- &:last-child {
- border-bottom: none;
- }
-
- &:active {
- background: $bg-color-page;
- }
-
- &.active {
- background: $success-color-bg;
- }
- }
- .type-icon {
- width: 72rpx;
- height: 72rpx;
- border-radius: 16rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-right: 20rpx;
-
- &.inventory {
- background: $success-color-bg;
-
- .icon-inner {
- width: 32rpx;
- height: 40rpx;
- border: 3rpx solid $primary-color;
- border-radius: 4rpx;
- position: relative;
-
- &::before {
- content: '';
- position: absolute;
- top: 8rpx;
- left: 6rpx;
- width: 14rpx;
- height: 3rpx;
- background: $primary-color;
- border-radius: 2rpx;
- }
-
- &::after {
- content: '';
- position: absolute;
- top: 16rpx;
- left: 6rpx;
- width: 10rpx;
- height: 3rpx;
- background: $primary-color;
- border-radius: 2rpx;
- }
- }
- }
-
- &.delivery {
- background: $accent-color-bg;
-
- .icon-inner {
- width: 36rpx;
- height: 28rpx;
- border: 3rpx solid $accent-color;
- border-radius: 4rpx;
- position: relative;
-
- &::before {
- content: '';
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 12rpx;
- height: 12rpx;
- background: $accent-color;
- border-radius: 50%;
- }
- }
- }
- }
- .type-info {
- flex: 1;
- }
- .type-name {
- display: block;
- font-size: 28rpx;
- font-weight: 500;
- color: $text-color-primary;
- margin-bottom: 4rpx;
- }
- .type-desc {
- font-size: 24rpx;
- color: $text-color-tertiary;
- }
- .type-check {
- width: 40rpx;
- height: 40rpx;
- background: $primary-color;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .check-icon {
- width: 16rpx;
- height: 10rpx;
- border-left: 3rpx solid $bg-color-card;
- border-bottom: 3rpx solid $bg-color-card;
- transform: rotate(-45deg);
- margin-top: -4rpx;
- }
- .photo-section {
- margin-bottom: 24rpx;
- }
- .photo-grid {
- display: flex;
- flex-wrap: wrap;
- gap: 16rpx;
- }
- .photo-item {
- width: calc(33.33% - 12rpx);
- aspect-ratio: 1;
- border-radius: 12rpx;
- overflow: hidden;
- position: relative;
- background: $bg-color-secondary;
- }
- .photo-image {
- width: 100%;
- height: 100%;
- }
- .photo-delete {
- position: absolute;
- top: 8rpx;
- right: 8rpx;
- width: 40rpx;
- height: 40rpx;
- background: rgba(0, 0, 0, 0.5);
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .delete-icon {
- width: 16rpx;
- height: 16rpx;
- position: relative;
-
- &::before, &::after {
- content: '';
- position: absolute;
- top: 50%;
- left: 50%;
- width: 20rpx;
- height: 3rpx;
- background: $bg-color-card;
- border-radius: 2rpx;
- }
-
- &::before {
- transform: translate(-50%, -50%) rotate(45deg);
- }
-
- &::after {
- transform: translate(-50%, -50%) rotate(-45deg);
- }
- }
- .photo-index {
- position: absolute;
- bottom: 8rpx;
- left: 8rpx;
- width: 36rpx;
- height: 36rpx;
- background: rgba(0, 0, 0, 0.5);
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 22rpx;
- color: $bg-color-card;
- }
- .photo-add {
- width: calc(33.33% - 12rpx);
- aspect-ratio: 1;
- border-radius: 12rpx;
- border: 2rpx dashed $text-color-placeholder;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- background: $bg-color-card;
-
- &:active {
- background: $bg-color-page;
- }
- }
- .add-icon {
- width: 48rpx;
- height: 48rpx;
- position: relative;
- margin-bottom: 8rpx;
- }
- .add-h, .add-v {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- background: $text-color-muted;
- border-radius: 2rpx;
- }
- .add-h {
- width: 32rpx;
- height: 4rpx;
- }
- .add-v {
- width: 4rpx;
- height: 32rpx;
- }
- .add-text {
- font-size: 24rpx;
- color: $text-color-muted;
- }
- .remark-section {
- margin-bottom: 24rpx;
- }
- .remark-input {
- width: 100%;
- min-height: 160rpx;
- background: $bg-color-card;
- border: 1rpx solid $border-color;
- border-radius: 16rpx;
- padding: 20rpx;
- font-size: 28rpx;
- color: $text-color-primary;
- box-sizing: border-box;
- }
- .placeholder {
- color: $text-color-muted;
- }
- .remark-count {
- text-align: right;
- margin-top: 8rpx;
-
- text {
- font-size: 24rpx;
- color: $text-color-muted;
- }
- }
- .offline-tip {
- display: flex;
- align-items: center;
- padding: 20rpx 24rpx;
- background: $warning-color-bg;
- border: 1rpx solid $primary-color-light;
- border-radius: 12rpx;
- margin-bottom: 24rpx;
- }
- .tip-icon {
- width: 32rpx;
- height: 32rpx;
- background: $warning-color;
- border-radius: 50%;
- margin-right: 12rpx;
- position: relative;
-
- &::before {
- content: '!';
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- font-size: 22rpx;
- font-weight: 700;
- color: $bg-color-card;
- }
- }
- .tip-text {
- flex: 1;
- font-size: 26rpx;
- color: $primary-color-dark;
- }
- .tip-action {
- font-size: 26rpx;
- color: $warning-color;
- font-weight: 500;
- }
- .submit-section {
- padding: 24rpx 0;
- padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
- }
- .submit-btn {
- width: 100%;
- height: 96rpx;
- line-height: 96rpx;
- padding: 0;
- background: linear-gradient(135deg, $primary-color 0%, $primary-color-dark 100%);
- border-radius: 48rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 32rpx;
- font-weight: 600;
- color: $bg-color-card;
- border: none;
-
- &.disabled {
- background: $text-color-placeholder;
- color: $text-color-muted;
- }
-
- &:active:not(.disabled) {
- opacity: 0.9;
- }
- }
- .btn-loading {
- width: 36rpx;
- height: 36rpx;
- border: 3rpx solid rgba(255, 255, 255, 0.3);
- border-top-color: $bg-color-card;
- border-radius: 50%;
- animation: rotate 0.8s linear infinite;
-
- @keyframes rotate {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
- }
- }
- .success-modal {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0, 0, 0, 0.5);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 1000;
- }
- .success-content {
- width: 560rpx;
- background: $bg-color-card;
- border-radius: 24rpx;
- padding: 48rpx;
- display: flex;
- flex-direction: column;
- align-items: center;
- }
- .success-icon {
- width: 120rpx;
- height: 120rpx;
- background: $success-color-bg;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-bottom: 24rpx;
- }
- .check-mark {
- width: 48rpx;
- height: 28rpx;
- border-left: 6rpx solid $primary-color;
- border-bottom: 6rpx solid $primary-color;
- transform: rotate(-45deg);
- margin-top: -12rpx;
- }
- .success-title {
- font-size: 36rpx;
- font-weight: 600;
- color: $text-color-primary;
- margin-bottom: 12rpx;
- }
- .success-time {
- font-size: 26rpx;
- color: $text-color-tertiary;
- margin-bottom: 32rpx;
- }
- .success-btn {
- width: 100%;
- height: 88rpx;
- line-height: 88rpx;
- padding: 0;
- background: $primary-color;
- border-radius: 44rpx;
- font-size: 30rpx;
- font-weight: 500;
- color: $bg-color-card;
- border: none;
-
- &:active {
- opacity: 0.9;
- }
- }
- </style>
|