Commit 6faaf5392dadbb20fef2b17cd75afa5beae68a4d

Authored by 杨鑫
1 parent 0d4ee10b

APP 登录门店对接

美国版/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 &#39;vue&#39;
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(() =&gt; {
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(&#39;&#39;)
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 = () =&gt; {
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 &#39;../../utils/statusBar&#39;
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 = () =&gt; {
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 = () =&gt; {
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
... ... @@ -8,5 +8,11 @@ export default defineConfig({
8 8 plugins: [uni()],
9 9 server: {
10 10 open: "/",
  11 + proxy: {
  12 + "/api": {
  13 + target: "http://flus-test.3ffoodsafety.com",
  14 + changeOrigin: true,
  15 + },
  16 + },
11 17 },
12 18 });
... ...
美国版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`** 字段,否则无法通过邮箱登录。
... ...