Commit 6faaf5392dadbb20fef2b17cd75afa5beae68a4d
1 parent
0d4ee10b
APP 登录门店对接
Showing
17 changed files
with
703 additions
and
84 deletions
美国版/Food Labeling Management App UniApp/src/components/LocationPicker.vue
| 1 | 1 | <template> |
| 2 | 2 | <view class="loc-root"> |
| 3 | - <view class="loc-trigger" @click.stop="showPicker = true"> | |
| 4 | - <text class="loc-text">LOC-{{ currentId }}</text> | |
| 3 | + <view class="loc-trigger" @click.stop="openPicker"> | |
| 4 | + <text class="loc-text">{{ displayCode || '—' }}</text> | |
| 5 | 5 | <AppIcon name="chevronDown" size="sm" color="white" /> |
| 6 | 6 | </view> |
| 7 | 7 | |
| ... | ... | @@ -25,8 +25,8 @@ |
| 25 | 25 | <AppIcon name="mapPin" size="sm" :color="currentId === s.id ? 'white' : 'gray'" /> |
| 26 | 26 | </view> |
| 27 | 27 | <view class="picker-info"> |
| 28 | - <text class="picker-name">{{ t(s.nameKey) }}</text> | |
| 29 | - <text class="picker-addr">{{ s.address }}, {{ s.city }}</text> | |
| 28 | + <text class="picker-name">{{ s.locationName }}</text> | |
| 29 | + <text class="picker-addr">{{ s.fullAddress }}</text> | |
| 30 | 30 | </view> |
| 31 | 31 | <view v-if="currentId === s.id" class="picker-check"> |
| 32 | 32 | <AppIcon name="check" size="sm" color="white" /> |
| ... | ... | @@ -39,30 +39,57 @@ |
| 39 | 39 | </template> |
| 40 | 40 | |
| 41 | 41 | <script setup lang="ts"> |
| 42 | -import { ref } from 'vue' | |
| 42 | +import { ref, computed, watch } from 'vue' | |
| 43 | 43 | import { useI18n } from 'vue-i18n' |
| 44 | 44 | import AppIcon from './AppIcon.vue' |
| 45 | -import { storeList, getCurrentStoreId, switchStore } from '../utils/stores' | |
| 45 | +import { | |
| 46 | + getCurrentStoreId, | |
| 47 | + switchStore, | |
| 48 | + getBoundLocations, | |
| 49 | + getCurrentLocationCode, | |
| 50 | +} from '../utils/stores' | |
| 51 | +import type { UsAppBoundLocationDto } from '../services/usAppAuth' | |
| 46 | 52 | |
| 47 | 53 | const { t } = useI18n() |
| 48 | -const stores = storeList | |
| 54 | +const stores = ref<UsAppBoundLocationDto[]>([]) | |
| 49 | 55 | const currentId = ref(getCurrentStoreId()) |
| 50 | 56 | const showPicker = ref(false) |
| 51 | 57 | |
| 52 | -const handleSelect = (s: typeof stores[0]) => { | |
| 58 | +function refreshList() { | |
| 59 | + stores.value = getBoundLocations().filter((s) => s.state !== false) | |
| 60 | +} | |
| 61 | + | |
| 62 | +const displayCode = computed(() => { | |
| 63 | + if (currentId.value) { | |
| 64 | + const row = stores.value.find((x) => x.id === currentId.value) | |
| 65 | + if (row?.locationCode) return row.locationCode | |
| 66 | + } | |
| 67 | + return getCurrentLocationCode() | |
| 68 | +}) | |
| 69 | + | |
| 70 | +function openPicker() { | |
| 71 | + refreshList() | |
| 72 | + currentId.value = getCurrentStoreId() | |
| 73 | + showPicker.value = true | |
| 74 | +} | |
| 75 | + | |
| 76 | +watch(showPicker, (v) => { | |
| 77 | + if (v) refreshList() | |
| 78 | +}) | |
| 79 | + | |
| 80 | +const handleSelect = (s: UsAppBoundLocationDto) => { | |
| 53 | 81 | if (s.id === currentId.value) { |
| 54 | 82 | showPicker.value = false |
| 55 | 83 | return |
| 56 | 84 | } |
| 57 | - const name = t(s.nameKey) | |
| 58 | - switchStore(s.id, name) | |
| 85 | + switchStore(s.id, s.locationName, s.locationCode) | |
| 59 | 86 | currentId.value = s.id |
| 60 | 87 | showPicker.value = false |
| 61 | - uni.showToast({ title: 'Switched to ' + name, icon: 'success' }) | |
| 88 | + uni.showToast({ title: t('location.storeSwitched'), icon: 'success' }) | |
| 62 | 89 | setTimeout(() => { |
| 63 | 90 | const pages = getCurrentPages() |
| 64 | - const cur = pages[pages.length - 1] as any | |
| 65 | - if (cur && cur.route) { | |
| 91 | + const cur = pages[pages.length - 1] as { route?: string } | |
| 92 | + if (cur?.route) { | |
| 66 | 93 | uni.redirectTo({ url: '/' + cur.route }) |
| 67 | 94 | } |
| 68 | 95 | }, 800) | ... | ... |
美国版/Food Labeling Management App UniApp/src/components/SideMenu.vue
| ... | ... | @@ -78,6 +78,7 @@ import { computed, ref, watch, nextTick } from 'vue' |
| 78 | 78 | import { useI18n } from 'vue-i18n' |
| 79 | 79 | import AppIcon from './AppIcon.vue' |
| 80 | 80 | import { getStatusBarHeight, getBottomSafeArea } from '../utils/statusBar' |
| 81 | +import { clearAuthSession } from '../utils/authSession' | |
| 81 | 82 | |
| 82 | 83 | const props = defineProps<{ |
| 83 | 84 | modelValue: boolean |
| ... | ... | @@ -165,10 +166,7 @@ const currentPath = computed(() => { |
| 165 | 166 | const isActive = (path: string) => !!path && path !== '__logout__' && currentPath.value === path |
| 166 | 167 | |
| 167 | 168 | const handleLogout = () => { |
| 168 | - uni.removeStorageSync('isLoggedIn') | |
| 169 | - uni.removeStorageSync('userName') | |
| 170 | - uni.removeStorageSync('storeName') | |
| 171 | - uni.removeStorageSync('storeId') | |
| 169 | + clearAuthSession() | |
| 172 | 170 | uni.redirectTo({ url: '/pages/login/login' }) |
| 173 | 171 | } |
| 174 | 172 | ... | ... |
美国版/Food Labeling Management App UniApp/src/env.d.ts
| 1 | 1 | /// <reference types="vite/client" /> |
| 2 | 2 | |
| 3 | +interface ImportMetaEnv { | |
| 4 | + /** 美国版后端根地址,如 http://192.168.1.2:19001;H5 开发可留空走 Vite /api 代理 */ | |
| 5 | + readonly VITE_US_API_BASE?: string | |
| 6 | +} | |
| 7 | + | |
| 8 | +interface ImportMeta { | |
| 9 | + readonly env: ImportMetaEnv | |
| 10 | +} | |
| 11 | + | |
| 3 | 12 | declare module '*.vue' { |
| 4 | 13 | import { DefineComponent } from 'vue' |
| 5 | 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types | ... | ... |
美国版/Food Labeling Management App UniApp/src/locales/en.ts
| ... | ... | @@ -14,6 +14,10 @@ export default { |
| 14 | 14 | signIn: 'Sign In', |
| 15 | 15 | signingIn: 'Signing In...', |
| 16 | 16 | loginSuccess: 'Login successful', |
| 17 | + loginFailed: 'Login failed', | |
| 18 | + fillRequired: 'Please enter email and password', | |
| 19 | + noStoresBound: 'No stores are linked to this account', | |
| 20 | + refreshStoresFail: 'Could not refresh store list', | |
| 17 | 21 | copyright: '© 2026 Food Label System. All rights reserved.', |
| 18 | 22 | selectStore: 'Select Store', |
| 19 | 23 | welcomeUser: 'Welcome!', | ... | ... |
美国版/Food Labeling Management App UniApp/src/locales/zh.ts
| ... | ... | @@ -14,6 +14,10 @@ export default { |
| 14 | 14 | signIn: '登录', |
| 15 | 15 | signingIn: '登录中...', |
| 16 | 16 | loginSuccess: '登录成功', |
| 17 | + loginFailed: '登录失败', | |
| 18 | + fillRequired: '请输入邮箱和密码', | |
| 19 | + noStoresBound: '当前账号未绑定门店', | |
| 20 | + refreshStoresFail: '门店列表刷新失败', | |
| 17 | 21 | copyright: '© 2026 食品标签系统。保留所有权利。', |
| 18 | 22 | selectStore: '选择店铺', |
| 19 | 23 | welcomeUser: '欢迎!', | ... | ... |
美国版/Food Labeling Management App UniApp/src/pages/index/index.vue
| ... | ... | @@ -41,10 +41,12 @@ |
| 41 | 41 | <script setup lang="ts"> |
| 42 | 42 | import { ref, computed } from 'vue' |
| 43 | 43 | import { useI18n } from 'vue-i18n' |
| 44 | +import { onShow } from '@dcloudio/uni-app' | |
| 44 | 45 | import AppIcon from '../../components/AppIcon.vue' |
| 45 | 46 | import SideMenu from '../../components/SideMenu.vue' |
| 46 | 47 | import LocationPicker from '../../components/LocationPicker.vue' |
| 47 | 48 | import { getStatusBarHeight } from '../../utils/statusBar' |
| 49 | +import { getAccessToken } from '../../utils/authSession' | |
| 48 | 50 | |
| 49 | 51 | const { t } = useI18n() |
| 50 | 52 | const statusBarHeight = getStatusBarHeight() |
| ... | ... | @@ -52,6 +54,16 @@ const statusBarHeight = getStatusBarHeight() |
| 52 | 54 | const storeName = computed(() => uni.getStorageSync('storeName') || 'MedVantage') |
| 53 | 55 | const isMenuOpen = ref(false) |
| 54 | 56 | |
| 57 | +onShow(() => { | |
| 58 | + if (!getAccessToken()) { | |
| 59 | + uni.reLaunch({ url: '/pages/login/login' }) | |
| 60 | + return | |
| 61 | + } | |
| 62 | + if (!uni.getStorageSync('storeId')) { | |
| 63 | + uni.redirectTo({ url: '/pages/store-select/store-select' }) | |
| 64 | + } | |
| 65 | +}) | |
| 66 | + | |
| 55 | 67 | const quickActions = computed(() => [ |
| 56 | 68 | { |
| 57 | 69 | label: t('Labeling'), | ... | ... |
美国版/Food Labeling Management App UniApp/src/pages/login/login.vue
| ... | ... | @@ -32,7 +32,7 @@ |
| 32 | 32 | </view> |
| 33 | 33 | <view class="form-row"> |
| 34 | 34 | <view class="remember-row"> |
| 35 | - <switch :checked="rememberMe" @change="rememberMe = $event.detail.value" :color="'#1F3A8A'" /> | |
| 35 | + <switch :checked="rememberMe" color="#1F3A8A" @change="onRememberChange" /> | |
| 36 | 36 | <text class="remember-text">{{ t('login.rememberMe') }}</text> |
| 37 | 37 | </view> |
| 38 | 38 | <text class="forgot-link">{{ t('login.forgotPassword') }}</text> |
| ... | ... | @@ -51,9 +51,16 @@ |
| 51 | 51 | </template> |
| 52 | 52 | |
| 53 | 53 | <script setup lang="ts"> |
| 54 | -import { ref } from 'vue' | |
| 54 | +import { ref, onMounted } from 'vue' | |
| 55 | 55 | import { useI18n } from 'vue-i18n' |
| 56 | 56 | import { getStatusBarHeight } from '../../utils/statusBar' |
| 57 | +import { usAppLogin } from '../../services/usAppAuth' | |
| 58 | +import { | |
| 59 | + saveAuthSession, | |
| 60 | + loadSavedCredentials, | |
| 61 | + saveRememberPreference, | |
| 62 | + isLoggedIn, | |
| 63 | +} from '../../utils/authSession' | |
| 57 | 64 | |
| 58 | 65 | const { t } = useI18n() |
| 59 | 66 | const statusBarHeight = getStatusBarHeight() |
| ... | ... | @@ -62,15 +69,67 @@ const password = ref('') |
| 62 | 69 | const rememberMe = ref(false) |
| 63 | 70 | const isLoading = ref(false) |
| 64 | 71 | |
| 65 | -const handleLogin = () => { | |
| 72 | +function displayNameFromEmail(mail: string): string { | |
| 73 | + const m = mail.trim() | |
| 74 | + if (!m) return 'Employee' | |
| 75 | + const at = m.indexOf('@') | |
| 76 | + return at > 0 ? m.slice(0, at) : m | |
| 77 | +} | |
| 78 | + | |
| 79 | +onMounted(() => { | |
| 80 | + if (isLoggedIn()) { | |
| 81 | + const sid = uni.getStorageSync('storeId') | |
| 82 | + if (sid) { | |
| 83 | + uni.reLaunch({ url: '/pages/index/index' }) | |
| 84 | + } else { | |
| 85 | + uni.reLaunch({ url: '/pages/store-select/store-select' }) | |
| 86 | + } | |
| 87 | + return | |
| 88 | + } | |
| 89 | + const saved = loadSavedCredentials() | |
| 90 | + if (saved.remember) { | |
| 91 | + rememberMe.value = true | |
| 92 | + email.value = saved.email | |
| 93 | + password.value = saved.password | |
| 94 | + } | |
| 95 | +}) | |
| 96 | + | |
| 97 | +function onRememberChange(e: { detail?: { value?: boolean } }) { | |
| 98 | + rememberMe.value = !!e.detail?.value | |
| 99 | +} | |
| 100 | + | |
| 101 | +const handleLogin = async () => { | |
| 102 | + const em = email.value.trim() | |
| 103 | + const pw = password.value | |
| 104 | + if (!em || !pw) { | |
| 105 | + uni.showToast({ title: t('login.fillRequired'), icon: 'none' }) | |
| 106 | + return | |
| 107 | + } | |
| 66 | 108 | isLoading.value = true |
| 67 | - setTimeout(() => { | |
| 68 | - uni.setStorageSync('isLoggedIn', 'true') | |
| 69 | - uni.setStorageSync('userName', 'John Smith') | |
| 109 | + try { | |
| 110 | + const res = await usAppLogin({ email: em, password: pw }) | |
| 111 | + if (!res.token) { | |
| 112 | + uni.showToast({ title: t('login.loginFailed'), icon: 'none' }) | |
| 113 | + return | |
| 114 | + } | |
| 115 | + saveRememberPreference(rememberMe.value, em, pw) | |
| 116 | + saveAuthSession({ | |
| 117 | + token: res.token, | |
| 118 | + refreshToken: res.refreshToken || '', | |
| 119 | + locations: res.locations ?? [], | |
| 120 | + displayName: displayNameFromEmail(em), | |
| 121 | + email: em, | |
| 122 | + }) | |
| 70 | 123 | uni.showToast({ title: t('login.loginSuccess'), icon: 'success' }) |
| 71 | - uni.redirectTo({ url: '/pages/store-select/store-select' }) | |
| 124 | + setTimeout(() => { | |
| 125 | + uni.redirectTo({ url: '/pages/store-select/store-select' }) | |
| 126 | + }, 400) | |
| 127 | + } catch (e: unknown) { | |
| 128 | + const msg = e instanceof Error ? e.message : String(e) | |
| 129 | + uni.showToast({ title: msg || t('login.loginFailed'), icon: 'none', duration: 2500 }) | |
| 130 | + } finally { | |
| 72 | 131 | isLoading.value = false |
| 73 | - }, 1000) | |
| 132 | + } | |
| 74 | 133 | } |
| 75 | 134 | </script> |
| 76 | 135 | ... | ... |
美国版/Food Labeling Management App UniApp/src/pages/more/location.vue
| ... | ... | @@ -23,12 +23,11 @@ |
| 23 | 23 | </view> |
| 24 | 24 | <view class="store-highlight-texts"> |
| 25 | 25 | <text class="store-badge-label">{{ t('location.currentStore') }}</text> |
| 26 | - <text class="store-highlight-name">{{ t(currentStore.nameKey) }}</text> | |
| 26 | + <text class="store-highlight-name">{{ currentStore.locationName }}</text> | |
| 27 | 27 | </view> |
| 28 | 28 | </view> |
| 29 | 29 | <view class="store-address-block"> |
| 30 | - <text class="store-address-line">{{ currentStore.address }}</text> | |
| 31 | - <text class="store-address-line">{{ currentStore.city }}</text> | |
| 30 | + <text class="store-address-line">{{ currentStore.fullAddress }}</text> | |
| 32 | 31 | </view> |
| 33 | 32 | </view> |
| 34 | 33 | |
| ... | ... | @@ -104,8 +103,8 @@ |
| 104 | 103 | <AppIcon name="mapPin" size="sm" :color="selectedId === s.id ? 'white' : 'gray'" /> |
| 105 | 104 | </view> |
| 106 | 105 | <view class="store-info-col"> |
| 107 | - <text class="store-item-name">{{ t(s.nameKey) }}</text> | |
| 108 | - <text class="store-item-addr">{{ s.address }}, {{ s.city }}</text> | |
| 106 | + <text class="store-item-name">{{ s.locationName }}</text> | |
| 107 | + <text class="store-item-addr">{{ s.fullAddress }}</text> | |
| 109 | 108 | </view> |
| 110 | 109 | <view v-if="selectedId === s.id" class="check-circle"> |
| 111 | 110 | <AppIcon name="check" size="sm" color="white" /> |
| ... | ... | @@ -130,25 +129,52 @@ |
| 130 | 129 | <script setup lang="ts"> |
| 131 | 130 | import { ref, computed } from 'vue' |
| 132 | 131 | import { useI18n } from 'vue-i18n' |
| 132 | +import { onShow } from '@dcloudio/uni-app' | |
| 133 | 133 | import AppIcon from '../../components/AppIcon.vue' |
| 134 | 134 | import SideMenu from '../../components/SideMenu.vue' |
| 135 | 135 | import LocationPicker from '../../components/LocationPicker.vue' |
| 136 | 136 | import { getStatusBarHeight } from '../../utils/statusBar' |
| 137 | +import { getBoundLocations, switchStore, getCurrentStoreId } from '../../utils/stores' | |
| 137 | 138 | |
| 138 | 139 | const { t } = useI18n() |
| 139 | 140 | const statusBarHeight = getStatusBarHeight() |
| 140 | 141 | const isMenuOpen = ref(false) |
| 141 | 142 | const showDialog = ref(false) |
| 142 | -const selectedId = ref(uni.getStorageSync('storeId') || '1') | |
| 143 | - | |
| 144 | -const stores = [ | |
| 145 | - { id: '1', nameKey: 'login.store1', address: '123 Main St', city: 'New York, NY 10001', phone: '(212) 555-0101', hours: 'Mon-Fri: 6:00 AM - 10:00 PM', manager: 'Sarah Johnson', managerPhone: '(212) 555-0111' }, | |
| 146 | - { id: '2', nameKey: 'login.store2', address: '456 Oak Ave', city: 'Brooklyn, NY 11201', phone: '(718) 555-0102', hours: 'Mon-Fri: 7:00 AM - 11:00 PM', manager: 'Michael Chen', managerPhone: '(718) 555-0112' }, | |
| 147 | - { id: '3', nameKey: 'login.store3', address: '789 Pine Rd', city: 'Queens, NY 11354', phone: '(718) 555-0103', hours: 'Mon-Sat: 6:00 AM - 9:00 PM', manager: 'Emily Rodriguez', managerPhone: '(718) 555-0113' }, | |
| 148 | - { id: '4', nameKey: 'login.store4', address: '321 Elm St', city: 'Manhattan, NY 10002', phone: '(212) 555-0104', hours: 'Daily: 6:00 AM - 11:00 PM', manager: 'David Kim', managerPhone: '(212) 555-0114' }, | |
| 149 | -] | |
| 143 | +const selectedId = ref(getCurrentStoreId()) | |
| 144 | + | |
| 145 | +const stores = computed(() => getBoundLocations().filter((s) => s.state !== false)) | |
| 146 | + | |
| 147 | +const EM = '—' | |
| 148 | + | |
| 149 | +const currentStore = computed(() => { | |
| 150 | + const list = stores.value | |
| 151 | + const id = selectedId.value || getCurrentStoreId() | |
| 152 | + const row = list.find((s) => s.id === id) || list[0] | |
| 153 | + if (!row) { | |
| 154 | + return { | |
| 155 | + id: '', | |
| 156 | + locationName: EM, | |
| 157 | + fullAddress: EM, | |
| 158 | + phone: EM, | |
| 159 | + hours: EM, | |
| 160 | + manager: EM, | |
| 161 | + managerPhone: EM, | |
| 162 | + } | |
| 163 | + } | |
| 164 | + return { | |
| 165 | + id: row.id, | |
| 166 | + locationName: row.locationName, | |
| 167 | + fullAddress: row.fullAddress || EM, | |
| 168 | + phone: EM, | |
| 169 | + hours: EM, | |
| 170 | + manager: EM, | |
| 171 | + managerPhone: EM, | |
| 172 | + } | |
| 173 | +}) | |
| 150 | 174 | |
| 151 | -const currentStore = computed(() => stores.find((s) => s.id === selectedId.value) || stores[0]) | |
| 175 | +onShow(() => { | |
| 176 | + selectedId.value = getCurrentStoreId() | |
| 177 | +}) | |
| 152 | 178 | |
| 153 | 179 | const goBack = () => { |
| 154 | 180 | const pages = getCurrentPages() |
| ... | ... | @@ -161,10 +187,9 @@ const goBack = () => { |
| 161 | 187 | |
| 162 | 188 | const handleSwitch = () => { |
| 163 | 189 | if (selectedId.value === currentStore.value.id) return |
| 164 | - const s = stores.find((x) => x.id === selectedId.value) | |
| 190 | + const s = stores.value.find((x) => x.id === selectedId.value) | |
| 165 | 191 | if (s) { |
| 166 | - uni.setStorageSync('storeId', selectedId.value) | |
| 167 | - uni.setStorageSync('storeName', t(s.nameKey)) | |
| 192 | + switchStore(s.id, s.locationName, s.locationCode) | |
| 168 | 193 | uni.showToast({ title: t('location.storeSwitched'), icon: 'success' }) |
| 169 | 194 | showDialog.value = false |
| 170 | 195 | } | ... | ... |
美国版/Food Labeling Management App UniApp/src/pages/more/more.vue
| ... | ... | @@ -92,23 +92,21 @@ |
| 92 | 92 | </template> |
| 93 | 93 | |
| 94 | 94 | <script setup lang="ts"> |
| 95 | -import { ref } from 'vue' | |
| 95 | +import { ref, computed } from 'vue' | |
| 96 | 96 | import { useI18n } from 'vue-i18n' |
| 97 | 97 | import AppIcon from '../../components/AppIcon.vue' |
| 98 | 98 | import SideMenu from '../../components/SideMenu.vue' |
| 99 | +import { clearAuthSession } from '../../utils/authSession' | |
| 99 | 100 | |
| 100 | 101 | const { t } = useI18n() |
| 101 | -const userName = 'John Smith' | |
| 102 | +const userName = computed(() => uni.getStorageSync('userName') || 'Employee') | |
| 102 | 103 | const showLogout = ref(false) |
| 103 | 104 | const isMenuOpen = ref(false) |
| 104 | 105 | |
| 105 | 106 | const navTo = (path: string) => uni.navigateTo({ url: path }) |
| 106 | 107 | |
| 107 | 108 | const handleLogout = () => { |
| 108 | - uni.removeStorageSync('isLoggedIn') | |
| 109 | - uni.removeStorageSync('userName') | |
| 110 | - uni.removeStorageSync('storeName') | |
| 111 | - uni.removeStorageSync('storeId') | |
| 109 | + clearAuthSession() | |
| 112 | 110 | uni.redirectTo({ url: '/pages/login/login' }) |
| 113 | 111 | } |
| 114 | 112 | </script> | ... | ... |
美国版/Food Labeling Management App UniApp/src/pages/more/profile.vue
| ... | ... | @@ -86,8 +86,8 @@ import { getStatusBarHeight } from '../../utils/statusBar' |
| 86 | 86 | const { t } = useI18n() |
| 87 | 87 | const statusBarHeight = getStatusBarHeight() |
| 88 | 88 | const isMenuOpen = ref(false) |
| 89 | -const name = ref(uni.getStorageSync('userName') || 'John Smith') | |
| 90 | -const email = ref('john.smith@company.com') | |
| 89 | +const name = ref(uni.getStorageSync('userName') || 'Employee') | |
| 90 | +const email = ref(uni.getStorageSync('user_email') || '') | |
| 91 | 91 | const phone = ref('+1 (555) 123-4567') |
| 92 | 92 | const employeeId = ref('EMP-2024-001') |
| 93 | 93 | ... | ... |
美国版/Food Labeling Management App UniApp/src/pages/store-select/store-select.vue
| ... | ... | @@ -12,6 +12,9 @@ |
| 12 | 12 | </view> |
| 13 | 13 | |
| 14 | 14 | <view class="list"> |
| 15 | + <view v-if="!loading && stores.length === 0" class="empty-hint"> | |
| 16 | + <text class="empty-text">{{ t('login.noStoresBound') }}</text> | |
| 17 | + </view> | |
| 15 | 18 | <view |
| 16 | 19 | v-for="store in stores" |
| 17 | 20 | :key="store.id" |
| ... | ... | @@ -23,13 +26,12 @@ |
| 23 | 26 | <AppIcon name="mapPin" size="md" :color="selectedStore === store.id ? 'white' : 'gray'" /> |
| 24 | 27 | </view> |
| 25 | 28 | <view class="card-content"> |
| 26 | - <text class="card-title">{{ t(store.nameKey) }}</text> | |
| 29 | + <text class="card-title">{{ store.locationName }}</text> | |
| 27 | 30 | <view class="info-row"> |
| 28 | 31 | <AppIcon name="mapPin" size="sm" color="gray" /> |
| 29 | - <text class="info">{{ store.address }}</text> | |
| 32 | + <text class="info">{{ store.fullAddress }}</text> | |
| 30 | 33 | </view> |
| 31 | - <text class="info">{{ t('location.storeManager') }}: {{ store.manager }}</text> | |
| 32 | - <text class="info">{{ t('location.storePhone') }}: {{ store.phone }}</text> | |
| 34 | + <text v-if="store.locationCode" class="info muted">{{ store.locationCode }}</text> | |
| 33 | 35 | </view> |
| 34 | 36 | <view class="card-check"> |
| 35 | 37 | <AppIcon |
| ... | ... | @@ -51,44 +53,75 @@ |
| 51 | 53 | <view class="bottom-bar" :style="{ paddingBottom: (bottomSafeArea + 24) + 'px' }"> |
| 52 | 54 | <view |
| 53 | 55 | class="confirm-btn" |
| 54 | - :class="{ disabled: !selectedStore }" | |
| 56 | + :class="{ disabled: !selectedStore || loading }" | |
| 55 | 57 | @click="handleConfirm" |
| 56 | 58 | > |
| 57 | - <text class="confirm-btn-text">{{ t('common.confirm') }}</text> | |
| 59 | + <text class="confirm-btn-text">{{ loading ? '…' : t('common.confirm') }}</text> | |
| 58 | 60 | </view> |
| 59 | 61 | </view> |
| 60 | 62 | </view> |
| 61 | 63 | </template> |
| 62 | 64 | |
| 63 | 65 | <script setup lang="ts"> |
| 64 | -import { ref } from 'vue' | |
| 66 | +import { ref, computed, onMounted } from 'vue' | |
| 65 | 67 | import { useI18n } from 'vue-i18n' |
| 68 | +import { onShow } from '@dcloudio/uni-app' | |
| 66 | 69 | import AppIcon from '../../components/AppIcon.vue' |
| 67 | 70 | import { getStatusBarHeight, getBottomSafeArea } from '../../utils/statusBar' |
| 71 | +import { usAppFetchMyLocations, type UsAppBoundLocationDto } from '../../services/usAppAuth' | |
| 72 | +import { setBoundLocations, getBoundLocations } from '../../utils/authSession' | |
| 73 | +import { switchStore } from '../../utils/stores' | |
| 68 | 74 | |
| 69 | 75 | const { t } = useI18n() |
| 70 | 76 | const statusBarHeight = getStatusBarHeight() |
| 71 | 77 | const bottomSafeArea = getBottomSafeArea() |
| 72 | -const userName = uni.getStorageSync('userName') || 'Employee' | |
| 78 | +const userName = computed(() => uni.getStorageSync('userName') || 'Employee') | |
| 73 | 79 | const selectedStore = ref('') |
| 80 | +const stores = ref<UsAppBoundLocationDto[]>([]) | |
| 81 | +const loading = ref(false) | |
| 82 | + | |
| 83 | +function applyList(list: UsAppBoundLocationDto[]) { | |
| 84 | + const enabled = list.filter((s) => s.state !== false) | |
| 85 | + stores.value = enabled | |
| 86 | + setBoundLocations(enabled) | |
| 87 | +} | |
| 88 | + | |
| 89 | +async function refreshFromApi() { | |
| 90 | + loading.value = true | |
| 91 | + try { | |
| 92 | + const list = await usAppFetchMyLocations() | |
| 93 | + applyList(list) | |
| 94 | + } catch { | |
| 95 | + applyList(getBoundLocations()) | |
| 96 | + uni.showToast({ title: t('login.refreshStoresFail'), icon: 'none' }) | |
| 97 | + } finally { | |
| 98 | + loading.value = false | |
| 99 | + } | |
| 100 | +} | |
| 74 | 101 | |
| 75 | -const stores = [ | |
| 76 | - { id: '1', nameKey: 'login.store1', address: '123 Main St, New York, NY 10001', manager: 'Sarah Johnson', phone: '(212) 555-0101' }, | |
| 77 | - { id: '2', nameKey: 'login.store2', address: '456 Oak Ave, Brooklyn, NY 11201', manager: 'Michael Chen', phone: '(718) 555-0102' }, | |
| 78 | - { id: '3', nameKey: 'login.store3', address: '789 Pine Rd, Queens, NY 11354', manager: 'Emily Rodriguez', phone: '(718) 555-0103' }, | |
| 79 | - { id: '4', nameKey: 'login.store4', address: '321 Elm St, Manhattan, NY 10002', manager: 'David Kim', phone: '(212) 555-0104' }, | |
| 80 | -] | |
| 102 | +onMounted(() => { | |
| 103 | + applyList(getBoundLocations()) | |
| 104 | + refreshFromApi() | |
| 105 | +}) | |
| 106 | + | |
| 107 | +onShow(() => { | |
| 108 | + applyList(getBoundLocations()) | |
| 109 | +}) | |
| 81 | 110 | |
| 82 | 111 | const handleConfirm = () => { |
| 83 | - if (!selectedStore.value) { | |
| 84 | - uni.showToast({ title: t('login.selectStoreError'), icon: 'none' }) | |
| 112 | + if (loading.value || !selectedStore.value) { | |
| 113 | + if (!selectedStore.value) { | |
| 114 | + uni.showToast({ title: t('login.selectStoreError'), icon: 'none' }) | |
| 115 | + } | |
| 85 | 116 | return |
| 86 | 117 | } |
| 87 | - const store = stores.find((s) => s.id === selectedStore.value)! | |
| 88 | - uni.setStorageSync('storeId', selectedStore.value) | |
| 89 | - uni.setStorageSync('storeName', t(store.nameKey)) | |
| 118 | + const store = stores.value.find((s) => s.id === selectedStore.value) | |
| 119 | + if (!store) return | |
| 120 | + switchStore(store.id, store.locationName, store.locationCode) | |
| 90 | 121 | uni.showToast({ title: t('login.storeSelected'), icon: 'success' }) |
| 91 | - uni.redirectTo({ url: '/pages/index/index' }) | |
| 122 | + setTimeout(() => { | |
| 123 | + uni.redirectTo({ url: '/pages/index/index' }) | |
| 124 | + }, 400) | |
| 92 | 125 | } |
| 93 | 126 | </script> |
| 94 | 127 | |
| ... | ... | @@ -145,6 +178,16 @@ const handleConfirm = () => { |
| 145 | 178 | padding-bottom: 24rpx; |
| 146 | 179 | } |
| 147 | 180 | |
| 181 | +.empty-hint { | |
| 182 | + padding: 48rpx 24rpx; | |
| 183 | + text-align: center; | |
| 184 | +} | |
| 185 | + | |
| 186 | +.empty-text { | |
| 187 | + font-size: 28rpx; | |
| 188 | + color: #6b7280; | |
| 189 | +} | |
| 190 | + | |
| 148 | 191 | .card { |
| 149 | 192 | padding: 32rpx; |
| 150 | 193 | background: #ffffff; |
| ... | ... | @@ -204,6 +247,11 @@ const handleConfirm = () => { |
| 204 | 247 | margin-bottom: 6rpx; |
| 205 | 248 | } |
| 206 | 249 | |
| 250 | +.info.muted { | |
| 251 | + color: #9ca3af; | |
| 252 | + font-size: 24rpx; | |
| 253 | +} | |
| 254 | + | |
| 207 | 255 | .card-check { |
| 208 | 256 | width: 44rpx; |
| 209 | 257 | height: 44rpx; | ... | ... |
美国版/Food Labeling Management App UniApp/src/services/usAppAuth.ts
0 → 100644
| 1 | +import { buildApiUrl } from '../utils/apiBase' | |
| 2 | + | |
| 3 | +/** 与后端 UsAppBoundLocationDto 对齐 */ | |
| 4 | +export interface UsAppBoundLocationDto { | |
| 5 | + id: string | |
| 6 | + locationCode: string | |
| 7 | + locationName: string | |
| 8 | + fullAddress: string | |
| 9 | + state: boolean | |
| 10 | +} | |
| 11 | + | |
| 12 | +export interface UsAppLoginInput { | |
| 13 | + email: string | |
| 14 | + password: string | |
| 15 | + uuid?: string | |
| 16 | + code?: string | |
| 17 | +} | |
| 18 | + | |
| 19 | +export interface UsAppLoginOutputDto { | |
| 20 | + token: string | |
| 21 | + refreshToken: string | |
| 22 | + locations: UsAppBoundLocationDto[] | |
| 23 | +} | |
| 24 | + | |
| 25 | +function normalizeLocation(raw: Record<string, unknown>): UsAppBoundLocationDto { | |
| 26 | + return { | |
| 27 | + id: String(raw.id ?? raw.Id ?? ''), | |
| 28 | + locationCode: String(raw.locationCode ?? raw.LocationCode ?? ''), | |
| 29 | + locationName: String(raw.locationName ?? raw.LocationName ?? ''), | |
| 30 | + fullAddress: String(raw.fullAddress ?? raw.FullAddress ?? ''), | |
| 31 | + state: raw.state !== false && raw.State !== false, | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +function normalizeLoginOutput(raw: unknown): UsAppLoginOutputDto { | |
| 36 | + const o = raw as Record<string, unknown> | |
| 37 | + const locs = o.locations ?? o.Locations | |
| 38 | + const arr = Array.isArray(locs) ? locs : [] | |
| 39 | + return { | |
| 40 | + token: String(o.token ?? o.Token ?? ''), | |
| 41 | + refreshToken: String(o.refreshToken ?? o.RefreshToken ?? ''), | |
| 42 | + locations: arr.map((x) => normalizeLocation(x as Record<string, unknown>)), | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +function normalizeLocationList(raw: unknown): UsAppBoundLocationDto[] { | |
| 47 | + const arr = Array.isArray(raw) ? raw : [] | |
| 48 | + return arr.map((x) => normalizeLocation(x as Record<string, unknown>)) | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** | |
| 52 | + * 取出真实业务负载:支持 ABP `result`、以及项目统一包装 `{ succeeded, data }`(data 内为 token / 数组等) | |
| 53 | + */ | |
| 54 | +function unwrap<T>(data: unknown): T { | |
| 55 | + if (data == null || typeof data !== 'object') return data as T | |
| 56 | + const o = data as Record<string, unknown> | |
| 57 | + if ('result' in o && o.result !== undefined) { | |
| 58 | + return o.result as T | |
| 59 | + } | |
| 60 | + const payload = o.data ?? o.Data | |
| 61 | + if (payload !== undefined && payload !== null) { | |
| 62 | + return payload as T | |
| 63 | + } | |
| 64 | + return data as T | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** 统一解析后端错误文案(含统一返回体里的 errors、succeeded 等) */ | |
| 68 | +function parseErrorMessage(data: unknown): string { | |
| 69 | + if (data == null) return 'Request failed' | |
| 70 | + if (typeof data === 'string') return data | |
| 71 | + if (typeof data === 'object') { | |
| 72 | + const o = data as Record<string, unknown> | |
| 73 | + const errorsRaw = o.errors ?? o.Errors | |
| 74 | + if (typeof errorsRaw === 'string' && errorsRaw.trim()) return errorsRaw.trim() | |
| 75 | + if (Array.isArray(errorsRaw)) { | |
| 76 | + const parts = errorsRaw.map((x) => String(x)).filter(Boolean) | |
| 77 | + if (parts.length) return parts.join('; ') | |
| 78 | + } | |
| 79 | + const err = o.error as Record<string, unknown> | undefined | |
| 80 | + if (err && typeof err.message === 'string') return err.message | |
| 81 | + if (typeof o.message === 'string') return o.message | |
| 82 | + if (typeof o.error_description === 'string') return o.error_description | |
| 83 | + } | |
| 84 | + return 'Request failed' | |
| 85 | +} | |
| 86 | + | |
| 87 | +/** HTTP 200 但业务失败(如 succeeded: false、体内 statusCode 403) */ | |
| 88 | +function isBusinessFailurePayload(data: unknown): boolean { | |
| 89 | + if (data == null || typeof data !== 'object') return false | |
| 90 | + const o = data as Record<string, unknown> | |
| 91 | + if (o.succeeded === false || o.Succeeded === false) return true | |
| 92 | + const inner = o.statusCode ?? o.StatusCode | |
| 93 | + if (typeof inner === 'number' && inner >= 400) return true | |
| 94 | + return false | |
| 95 | +} | |
| 96 | + | |
| 97 | +function request<T>(options: { | |
| 98 | + path: string | |
| 99 | + method: 'GET' | 'POST' | |
| 100 | + data?: unknown | |
| 101 | + auth?: boolean | |
| 102 | +}): Promise<T> { | |
| 103 | + const header: Record<string, string> = { | |
| 104 | + 'Content-Type': 'application/json', | |
| 105 | + Accept: 'application/json', | |
| 106 | + } | |
| 107 | + if (options.auth) { | |
| 108 | + const token = uni.getStorageSync('access_token') | |
| 109 | + if (token) header.Authorization = `Bearer ${token}` | |
| 110 | + } | |
| 111 | + | |
| 112 | + return new Promise((resolve, reject) => { | |
| 113 | + uni.request({ | |
| 114 | + url: buildApiUrl(options.path), | |
| 115 | + method: options.method, | |
| 116 | + data: options.data, | |
| 117 | + header, | |
| 118 | + success: (res) => { | |
| 119 | + const status = res.statusCode ?? 0 | |
| 120 | + if (status >= 400) { | |
| 121 | + reject(new Error(parseErrorMessage(res.data))) | |
| 122 | + return | |
| 123 | + } | |
| 124 | + if (isBusinessFailurePayload(res.data)) { | |
| 125 | + reject(new Error(parseErrorMessage(res.data))) | |
| 126 | + return | |
| 127 | + } | |
| 128 | + try { | |
| 129 | + const body = unwrap<T>(res.data as unknown) | |
| 130 | + resolve(body) | |
| 131 | + } catch { | |
| 132 | + reject(new Error('Invalid response')) | |
| 133 | + } | |
| 134 | + }, | |
| 135 | + fail: (err) => { | |
| 136 | + reject(new Error(err.errMsg || 'Network error')) | |
| 137 | + }, | |
| 138 | + }) | |
| 139 | + }) | |
| 140 | +} | |
| 141 | + | |
| 142 | +/** POST /api/app/us-app-auth/login */ | |
| 143 | +export async function usAppLogin(input: UsAppLoginInput): Promise<UsAppLoginOutputDto> { | |
| 144 | + const raw = await request<unknown>({ | |
| 145 | + path: '/api/app/us-app-auth/login', | |
| 146 | + method: 'POST', | |
| 147 | + data: { | |
| 148 | + email: input.email.trim(), | |
| 149 | + password: input.password, | |
| 150 | + ...(input.uuid ? { uuid: input.uuid } : {}), | |
| 151 | + ...(input.code != null ? { code: input.code } : {}), | |
| 152 | + }, | |
| 153 | + }) | |
| 154 | + return normalizeLoginOutput(raw) | |
| 155 | +} | |
| 156 | + | |
| 157 | +/** GET /api/app/us-app-auth/my-locations */ | |
| 158 | +export async function usAppFetchMyLocations(): Promise<UsAppBoundLocationDto[]> { | |
| 159 | + const raw = await request<unknown>({ | |
| 160 | + path: '/api/app/us-app-auth/my-locations', | |
| 161 | + method: 'GET', | |
| 162 | + auth: true, | |
| 163 | + }) | |
| 164 | + return normalizeLocationList(raw) | |
| 165 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/apiBase.ts
0 → 100644
| 1 | +/** | |
| 2 | + * 美国版后端 API 根地址(不含末尾 /)。 | |
| 3 | + * - H5 开发:可在 .env.development 留空,配合 vite proxy 走同源 /api | |
| 4 | + * - App / 生产:在 .env 中设置 VITE_US_API_BASE,例如 http://192.168.1.10:19001 | |
| 5 | + */ | |
| 6 | +export function getApiBaseUrl(): string { | |
| 7 | + const fromEnv = (import.meta.env.VITE_US_API_BASE as string | undefined)?.trim() | |
| 8 | + if (fromEnv) return fromEnv.replace(/\/$/, '') | |
| 9 | + if (import.meta.env.DEV && typeof window !== 'undefined') return '' | |
| 10 | + return 'http://flus-test.3ffoodsafety.com' | |
| 11 | +} | |
| 12 | + | |
| 13 | +export function buildApiUrl(path: string): string { | |
| 14 | + const base = getApiBaseUrl() | |
| 15 | + const p = path.startsWith('/') ? path : `/${path}` | |
| 16 | + return base ? `${base}${p}` : p | |
| 17 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/authSession.ts
0 → 100644
| 1 | +import type { UsAppBoundLocationDto } from '../services/usAppAuth' | |
| 2 | + | |
| 3 | +const KEY_ACCESS = 'access_token' | |
| 4 | +const KEY_REFRESH = 'refresh_token' | |
| 5 | +const KEY_LOCATIONS = 'bound_locations_json' | |
| 6 | +const KEY_LOGGED = 'isLoggedIn' | |
| 7 | +const KEY_USER = 'userName' | |
| 8 | +const KEY_EMAIL = 'user_email' | |
| 9 | +const KEY_REMEMBER = 'login_remember_me' | |
| 10 | +const KEY_SAVED_EMAIL = 'login_saved_email' | |
| 11 | +const KEY_SAVED_PASSWORD = 'login_saved_password' | |
| 12 | + | |
| 13 | +export function saveAuthSession(payload: { | |
| 14 | + token: string | |
| 15 | + refreshToken: string | |
| 16 | + locations: UsAppBoundLocationDto[] | |
| 17 | + /** 展示用:无姓名接口时用邮箱本地部分或全文 */ | |
| 18 | + displayName: string | |
| 19 | + email: string | |
| 20 | +}): void { | |
| 21 | + uni.setStorageSync(KEY_ACCESS, payload.token) | |
| 22 | + uni.setStorageSync(KEY_REFRESH, payload.refreshToken) | |
| 23 | + uni.setStorageSync(KEY_LOCATIONS, JSON.stringify(payload.locations ?? [])) | |
| 24 | + uni.setStorageSync(KEY_LOGGED, 'true') | |
| 25 | + uni.setStorageSync(KEY_USER, payload.displayName) | |
| 26 | + uni.setStorageSync(KEY_EMAIL, payload.email) | |
| 27 | +} | |
| 28 | + | |
| 29 | +export function setBoundLocations(locations: UsAppBoundLocationDto[]): void { | |
| 30 | + uni.setStorageSync(KEY_LOCATIONS, JSON.stringify(locations ?? [])) | |
| 31 | +} | |
| 32 | + | |
| 33 | +export function getBoundLocations(): UsAppBoundLocationDto[] { | |
| 34 | + try { | |
| 35 | + const raw = uni.getStorageSync(KEY_LOCATIONS) | |
| 36 | + if (!raw || typeof raw !== 'string') return [] | |
| 37 | + const parsed = JSON.parse(raw) as unknown | |
| 38 | + if (!Array.isArray(parsed)) return [] | |
| 39 | + return parsed as UsAppBoundLocationDto[] | |
| 40 | + } catch { | |
| 41 | + return [] | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +export function clearAuthSession(): void { | |
| 46 | + uni.removeStorageSync(KEY_ACCESS) | |
| 47 | + uni.removeStorageSync(KEY_REFRESH) | |
| 48 | + uni.removeStorageSync(KEY_LOCATIONS) | |
| 49 | + uni.removeStorageSync(KEY_LOGGED) | |
| 50 | + uni.removeStorageSync(KEY_USER) | |
| 51 | + uni.removeStorageSync(KEY_EMAIL) | |
| 52 | + uni.removeStorageSync('storeId') | |
| 53 | + uni.removeStorageSync('storeName') | |
| 54 | + uni.removeStorageSync('storeLocationCode') | |
| 55 | +} | |
| 56 | + | |
| 57 | +export function isLoggedIn(): boolean { | |
| 58 | + return !!uni.getStorageSync(KEY_ACCESS) && uni.getStorageSync(KEY_LOGGED) === 'true' | |
| 59 | +} | |
| 60 | + | |
| 61 | +export function getAccessToken(): string { | |
| 62 | + return uni.getStorageSync(KEY_ACCESS) || '' | |
| 63 | +} | |
| 64 | + | |
| 65 | +export function saveRememberPreference(remember: boolean, email: string, password: string): void { | |
| 66 | + uni.setStorageSync(KEY_REMEMBER, remember ? '1' : '0') | |
| 67 | + if (remember) { | |
| 68 | + uni.setStorageSync(KEY_SAVED_EMAIL, email) | |
| 69 | + uni.setStorageSync(KEY_SAVED_PASSWORD, password) | |
| 70 | + } else { | |
| 71 | + uni.removeStorageSync(KEY_SAVED_EMAIL) | |
| 72 | + uni.removeStorageSync(KEY_SAVED_PASSWORD) | |
| 73 | + } | |
| 74 | +} | |
| 75 | + | |
| 76 | +export function loadSavedCredentials(): { email: string; password: string; remember: boolean } { | |
| 77 | + const remember = uni.getStorageSync(KEY_REMEMBER) === '1' | |
| 78 | + return { | |
| 79 | + remember, | |
| 80 | + email: remember ? (uni.getStorageSync(KEY_SAVED_EMAIL) || '') : '', | |
| 81 | + password: remember ? (uni.getStorageSync(KEY_SAVED_PASSWORD) || '') : '', | |
| 82 | + } | |
| 83 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/stores.ts
| 1 | -export interface StoreInfo { | |
| 2 | - id: string | |
| 3 | - nameKey: string | |
| 4 | - address: string | |
| 5 | - city: string | |
| 6 | -} | |
| 1 | +import type { UsAppBoundLocationDto } from '../services/usAppAuth' | |
| 2 | +import { getBoundLocations } from './authSession' | |
| 3 | + | |
| 4 | +export type StoreInfo = UsAppBoundLocationDto | |
| 7 | 5 | |
| 8 | -export const storeList: StoreInfo[] = [ | |
| 9 | - { id: '1', nameKey: 'login.store1', address: '123 Main St', city: 'New York, NY 10001' }, | |
| 10 | - { id: '2', nameKey: 'login.store2', address: '456 Oak Ave', city: 'Brooklyn, NY 11201' }, | |
| 11 | - { id: '3', nameKey: 'login.store3', address: '789 Pine Rd', city: 'Queens, NY 11354' }, | |
| 12 | - { id: '4', nameKey: 'login.store4', address: '321 Elm St', city: 'Manhattan, NY 10002' }, | |
| 13 | -] | |
| 6 | +export { getBoundLocations } from './authSession' | |
| 14 | 7 | |
| 15 | 8 | export function getCurrentStoreId(): string { |
| 16 | - return uni.getStorageSync('storeId') || '1' | |
| 9 | + return uni.getStorageSync('storeId') || '' | |
| 17 | 10 | } |
| 18 | 11 | |
| 19 | -export function switchStore(id: string, storeName: string) { | |
| 12 | +export function switchStore(id: string, storeName: string, locationCode?: string) { | |
| 20 | 13 | uni.setStorageSync('storeId', id) |
| 21 | 14 | uni.setStorageSync('storeName', storeName) |
| 15 | + if (locationCode != null && locationCode !== '') { | |
| 16 | + uni.setStorageSync('storeLocationCode', locationCode) | |
| 17 | + } | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** 顶部药丸展示用,如 LOC-1 */ | |
| 21 | +export function getCurrentLocationCode(): string { | |
| 22 | + const saved = uni.getStorageSync('storeLocationCode') | |
| 23 | + if (saved) return saved | |
| 24 | + const id = getCurrentStoreId() | |
| 25 | + if (!id) return '' | |
| 26 | + const row = getBoundLocations().find((l) => l.id === id) | |
| 27 | + return row?.locationCode || '' | |
| 22 | 28 | } | ... | ... |
美国版/Food Labeling Management App UniApp/vite.config.ts
美国版App登录接口说明.md
0 → 100644
| 1 | +# 美国版 App 登录接口说明 | |
| 2 | + | |
| 3 | +## 概述 | |
| 4 | + | |
| 5 | +美国版移动端认证由 `food-labeling-us` 模块的 **`UsAppAuthAppService`** 提供,采用 ABP 约定式动态 API。宿主统一前缀为 **`/api/app`**,建议以 Swagger 为准核对路径(本地示例:`http://localhost:19001/swagger`,搜索 `UsAppAuth`)。 | |
| 6 | + | |
| 7 | +| 说明 | 内容 | | |
| 8 | +|------|------| | |
| 9 | +| 账号标识 | 使用 **`User.Email`**(邮箱)登录,邮箱比对**忽略大小写** | | |
| 10 | +| 密码 | 与 Web 共用 `User` 表,校验方式与 RBAC **`AccountManager`** 一致(盐值 + `MD5Helper.SHA2Encode`) | | |
| 11 | +| 验证码 | 当配置 **`Rbac:EnableCaptcha`** 为 `true` 时,需先拉取图形验证码,本接口入参传 `uuid`、`code`;未开启时可传空或不传 | | |
| 12 | + | |
| 13 | +--- | |
| 14 | + | |
| 15 | +## 接口 1:App 登录 | |
| 16 | + | |
| 17 | +签发 **Access Token**、**Refresh Token**,并返回当前用户在 **`userlocation`** 中绑定的门店列表(关联 **`location`** 表详情)。 | |
| 18 | + | |
| 19 | +### HTTP | |
| 20 | + | |
| 21 | +- **方法**:`POST` | |
| 22 | +- **路径**:`/api/app/us-app-auth/login` | |
| 23 | +- **Content-Type**:`application/json` | |
| 24 | +- **鉴权**:无需登录(匿名) | |
| 25 | + | |
| 26 | +### 请求体参数(UsAppLoginInputVo) | |
| 27 | + | |
| 28 | +| 参数名(JSON) | 类型 | 必填 | 说明 | | |
| 29 | +|----------------|------|------|------| | |
| 30 | +| `email` | string | 是 | 登录邮箱,对应数据库 `User.Email` | | |
| 31 | +| `password` | string | 是 | 明文密码 | | |
| 32 | +| `uuid` | string | 条件 | 图形验证码 UUID;**开启验证码时必填** | | |
| 33 | +| `code` | string | 条件 | 图形验证码;**开启验证码时必填** | | |
| 34 | + | |
| 35 | +### 传参示例(请求 Body) | |
| 36 | + | |
| 37 | +未开启图形验证码时: | |
| 38 | + | |
| 39 | +```json | |
| 40 | +{ | |
| 41 | + "email": "admin@example.com", | |
| 42 | + "password": "123456" | |
| 43 | +} | |
| 44 | +``` | |
| 45 | + | |
| 46 | +开启图形验证码时(需与系统验证码接口返回的 `uuid`、用户输入的验证码一致): | |
| 47 | + | |
| 48 | +```json | |
| 49 | +{ | |
| 50 | + "email": "test@example.com", | |
| 51 | + "password": "您的密码", | |
| 52 | + "uuid": "验证码接口返回的 uuid", | |
| 53 | + "code": "用户看到的验证码" | |
| 54 | +} | |
| 55 | +``` | |
| 56 | + | |
| 57 | +### 响应体(UsAppLoginOutputDto) | |
| 58 | + | |
| 59 | +| 字段(JSON) | 类型 | 说明 | | |
| 60 | +|--------------|------|------| | |
| 61 | +| `token` | string | 访问令牌(Bearer),后续业务接口放在 Header `Authorization: Bearer {token}` | | |
| 62 | +| `refreshToken` | string | 刷新令牌(与系统账号体系一致,用于刷新 access token,具体用法与 Web 一致) | | |
| 63 | +| `locations` | array | 绑定门店列表,元素见下表 | | |
| 64 | + | |
| 65 | +#### `locations[]` 元素(UsAppBoundLocationDto) | |
| 66 | + | |
| 67 | +| 字段(JSON) | 类型 | 说明 | | |
| 68 | +|--------------|------|------| | |
| 69 | +| `id` | string | 门店主键(Guid 字符串) | | |
| 70 | +| `locationCode` | string | 业务编码,如 LOC-1 | | |
| 71 | +| `locationName` | string | 门店名称 | | |
| 72 | +| `fullAddress` | string | 拼接后的完整地址(街道、城市、州、邮编等;无数据时可能为 `"无"`) | | |
| 73 | +| `state` | bool | 门店是否启用 | | |
| 74 | + | |
| 75 | +### 响应示例 | |
| 76 | + | |
| 77 | +```json | |
| 78 | +{ | |
| 79 | + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", | |
| 80 | + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", | |
| 81 | + "locations": [ | |
| 82 | + { | |
| 83 | + "id": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f", | |
| 84 | + "locationCode": "LOC-1", | |
| 85 | + "locationName": "Downtown Kitchen", | |
| 86 | + "fullAddress": "123 Main St, New York, NY 10001", | |
| 87 | + "state": true | |
| 88 | + } | |
| 89 | + ] | |
| 90 | +} | |
| 91 | +``` | |
| 92 | + | |
| 93 | +### 常见错误提示(业务异常文案) | |
| 94 | + | |
| 95 | +- 邮箱或密码为空:`请输入合理数据!` | |
| 96 | +- 邮箱在库中不存在(未删除且启用用户中无匹配邮箱):`登录失败!邮箱不存在!` | |
| 97 | +- 密码错误:`登录失败!用户名或密码错误!`(与 `UserConst.Login_Error` 一致) | |
| 98 | +- 验证码错误(开启验证码时):`验证码错误` | |
| 99 | + | |
| 100 | +--- | |
| 101 | + | |
| 102 | +## 接口 2:获取当前账号绑定门店 | |
| 103 | + | |
| 104 | +无需重新登录即可刷新 **`userlocation`** 绑定门店列表(例如切换门店前先同步列表)。 | |
| 105 | + | |
| 106 | +### HTTP | |
| 107 | + | |
| 108 | +- **方法**:`GET` | |
| 109 | +- **路径**:`/api/app/us-app-auth/my-locations` | |
| 110 | +- **鉴权**:需要登录,请求头携带 **`Authorization: Bearer {token}`**(使用接口 1 返回的 `token`) | |
| 111 | + | |
| 112 | +### 请求参数 | |
| 113 | + | |
| 114 | +无 Query / Body 参数;用户身份由 JWT 解析。 | |
| 115 | + | |
| 116 | +### 传参示例 | |
| 117 | + | |
| 118 | +```http | |
| 119 | +GET /api/app/us-app-auth/my-locations HTTP/1.1 | |
| 120 | +Host: localhost:19001 | |
| 121 | +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... | |
| 122 | +``` | |
| 123 | + | |
| 124 | +若前端统一约定 GET 使用 `data` 封装,可自行在客户端组装;本接口服务端**不读取额外 Query 参数**。 | |
| 125 | + | |
| 126 | +### 响应体 | |
| 127 | + | |
| 128 | +与登录接口中 **`locations`** 相同:**`UsAppBoundLocationDto[]`**(数组)。 | |
| 129 | + | |
| 130 | +### 响应示例 | |
| 131 | + | |
| 132 | +```json | |
| 133 | +[ | |
| 134 | + { | |
| 135 | + "id": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f", | |
| 136 | + "locationCode": "LOC-1", | |
| 137 | + "locationName": "Downtown Kitchen", | |
| 138 | + "fullAddress": "123 Main St, New York, NY 10001", | |
| 139 | + "state": true | |
| 140 | + } | |
| 141 | +] | |
| 142 | +``` | |
| 143 | + | |
| 144 | +### 常见错误 | |
| 145 | + | |
| 146 | +- 未登录或 Token 无效:按网关/ABP 返回 401 及统一错误体 | |
| 147 | +- 无用户上下文:`用户未登录` | |
| 148 | + | |
| 149 | +--- | |
| 150 | + | |
| 151 | +## 与其他登录方式的区别 | |
| 152 | + | |
| 153 | +| 场景 | 说明 | | |
| 154 | +|------|------| | |
| 155 | +| Web 管理端 | 仍使用 RBAC **`AccountService.PostLoginAsync`**,一般为人 **`userName`** + 密码 | | |
| 156 | +| 美国版 App | **仅**本模块 **`/api/app/us-app-auth/login`** 使用 **邮箱 + 密码** | | |
| 157 | + | |
| 158 | +两者共用同一 `User` 表与 JWT 体系;App 端需保证账号已维护 **`Email`** 字段,否则无法通过邮箱登录。 | ... | ... |