Commit acf08c9c6a15436f1d06addc5e4012cc1b9bd3be

Authored by 杨鑫
2 parents abb6bea5 dbc9750f

Merge branch 'main' of http://39.98.150.180/wangming/Food-Labeling-Management-Platform

Showing 30 changed files with 2512 additions and 483 deletions
label-template-template-1773988794039 (1).json 0 → 100644
  1 +{
  2 + "id": "template-1773988794039",
  3 + "name": "未命名模板",
  4 + "labelType": "PRICE",
  5 + "unit": "inch",
  6 + "width": 4,
  7 + "height": 6,
  8 + "appliedLocation": "ALL",
  9 + "showRuler": true,
  10 + "showGrid": true,
  11 + "elements": [
  12 + {
  13 + "id": "el-1773989351080-vqc03nr",
  14 + "type": "IMAGE",
  15 + "x": 32,
  16 + "y": 24,
  17 + "width": 60,
  18 + "height": 60,
  19 + "rotation": "horizontal",
  20 + "border": "none",
  21 + "config": {
  22 + "src": "",
  23 + "scaleMode": "contain"
  24 + }
  25 + },
  26 + {
  27 + "id": "el-1773989452538-0ejrxoe",
  28 + "type": "TEXT_STATIC",
  29 + "x": 32,
  30 + "y": 104,
  31 + "width": 120,
  32 + "height": 24,
  33 + "rotation": "horizontal",
  34 + "border": "none",
  35 + "config": {
  36 + "text": "文本",
  37 + "fontFamily": "Arial",
  38 + "fontSize": 14,
  39 + "fontWeight": "normal",
  40 + "textAlign": "left"
  41 + }
  42 + },
  43 + {
  44 + "id": "el-1773989466493-ibbroio",
  45 + "type": "QRCODE",
  46 + "x": 32,
  47 + "y": 136,
  48 + "width": 80,
  49 + "height": 80,
  50 + "rotation": "horizontal",
  51 + "border": "none",
  52 + "config": {
  53 + "data": "https://example.com",
  54 + "errorLevel": "M"
  55 + }
  56 + },
  57 + {
  58 + "id": "el-1773989469008-f1l39qj",
  59 + "type": "BARCODE",
  60 + "x": 0,
  61 + "y": 224,
  62 + "width": 160,
  63 + "height": 48,
  64 + "rotation": "horizontal",
  65 + "border": "none",
  66 + "config": {
  67 + "barcodeType": "CODE128",
  68 + "data": "123456789",
  69 + "showText": true,
  70 + "orientation": "horizontal"
  71 + }
  72 + },
  73 + {
  74 + "id": "el-1773989473436-j7fdeh2",
  75 + "type": "BLANK",
  76 + "x": 32,
  77 + "y": 288,
  78 + "width": 48,
  79 + "height": 32,
  80 + "rotation": "horizontal",
  81 + "border": "none",
  82 + "config": {}
  83 + },
  84 + {
  85 + "id": "el-1773989483341-ifwcyjj",
  86 + "type": "TEXT_PRICE",
  87 + "x": 152,
  88 + "y": 24,
  89 + "width": 80,
  90 + "height": 24,
  91 + "rotation": "horizontal",
  92 + "border": "none",
  93 + "config": {
  94 + "text": "0.00",
  95 + "prefix": "¥",
  96 + "decimal": 2,
  97 + "fontFamily": "Arial",
  98 + "fontSize": 14,
  99 + "fontWeight": "bold",
  100 + "textAlign": "right"
  101 + }
  102 + },
  103 + {
  104 + "id": "el-1773989498031-e4d61j8",
  105 + "type": "IMAGE",
  106 + "x": 192,
  107 + "y": 56,
  108 + "width": 60,
  109 + "height": 60,
  110 + "rotation": "horizontal",
  111 + "border": "none",
  112 + "config": {
  113 + "src": "",
  114 + "scaleMode": "contain"
  115 + }
  116 + },
  117 + {
  118 + "id": "el-1773989505076-1lxccx7",
  119 + "type": "TEXT_PRODUCT",
  120 + "x": 200,
  121 + "y": 136,
  122 + "width": 120,
  123 + "height": 24,
  124 + "rotation": "horizontal",
  125 + "border": "none",
  126 + "config": {
  127 + "text": "商品名",
  128 + "fontFamily": "Arial",
  129 + "fontSize": 14,
  130 + "fontWeight": "normal",
  131 + "textAlign": "left"
  132 + }
  133 + },
  134 + {
  135 + "id": "el-1773989509805-ax3392v",
  136 + "type": "TEXT_STATIC",
  137 + "x": 192,
  138 + "y": 160,
  139 + "width": 120,
  140 + "height": 24,
  141 + "rotation": "horizontal",
  142 + "border": "none",
  143 + "config": {
  144 + "text": "文本",
  145 + "fontFamily": "Arial",
  146 + "fontSize": 14,
  147 + "fontWeight": "normal",
  148 + "textAlign": "left"
  149 + }
  150 + },
  151 + {
  152 + "id": "el-1773989512993-xt8bg7q",
  153 + "type": "QRCODE",
  154 + "x": 184,
  155 + "y": 184,
  156 + "width": 80,
  157 + "height": 80,
  158 + "rotation": "horizontal",
  159 + "border": "none",
  160 + "config": {
  161 + "data": "https://example.com",
  162 + "errorLevel": "M"
  163 + }
  164 + },
  165 + {
  166 + "id": "el-1773989525383-eji8p2s",
  167 + "type": "BARCODE",
  168 + "x": 0,
  169 + "y": 288,
  170 + "width": 160,
  171 + "height": 48,
  172 + "rotation": "horizontal",
  173 + "border": "none",
  174 + "config": {
  175 + "barcodeType": "CODE128",
  176 + "data": "123456789",
  177 + "showText": true,
  178 + "orientation": "horizontal"
  179 + }
  180 + },
  181 + {
  182 + "id": "el-1773989540159-dr2avdf",
  183 + "type": "NUTRITION",
  184 + "x": 184,
  185 + "y": 280,
  186 + "width": 200,
  187 + "height": 120,
  188 + "rotation": "horizontal",
  189 + "border": "none",
  190 + "config": {
  191 + "calories": 120,
  192 + "fat": "5g",
  193 + "protein": "3g",
  194 + "carbs": "10g",
  195 + "layout": "standard"
  196 + }
  197 + },
  198 + {
  199 + "id": "el-1773989549679-mcxrdnw",
  200 + "type": "TEXT_PRICE",
  201 + "x": 24,
  202 + "y": 352,
  203 + "width": 80,
  204 + "height": 24,
  205 + "rotation": "horizontal",
  206 + "border": "none",
  207 + "config": {
  208 + "text": "0.00",
  209 + "prefix": "¥",
  210 + "decimal": 2,
  211 + "fontFamily": "Arial",
  212 + "fontSize": 14,
  213 + "fontWeight": "bold",
  214 + "textAlign": "right"
  215 + }
  216 + }
  217 + ]
  218 +}
0 219 \ No newline at end of file
... ...
美国版/Food Labeling Management App UniApp/src/locales/en.ts
... ... @@ -53,6 +53,11 @@ export default {
53 53 connectFail: 'Connection failed',
54 54 bleNotAvailable: 'Bluetooth not available',
55 55 noDevices: 'No devices found',
  56 + pairedDevices: 'Paired Devices',
  57 + noPairedDevices: 'No paired Bluetooth devices',
  58 + classic: 'Classic',
  59 + ble: 'BLE',
  60 + dual: 'Dual',
56 61 printer1: { name: 'Kitchen Printer #1', location: 'Main Kitchen' },
57 62 printer2: { name: 'Kitchen Printer #2', location: 'Main Kitchen' },
58 63 printer3: { name: 'Prep Area Printer', location: 'Prep Station' },
... ...
美国版/Food Labeling Management App UniApp/src/locales/zh.ts
... ... @@ -53,6 +53,11 @@ export default {
53 53 connectFail: '连接失败',
54 54 bleNotAvailable: '蓝牙未开启或不可用',
55 55 noDevices: '未发现设备',
  56 + pairedDevices: '已配对设备',
  57 + noPairedDevices: '没有已配对的蓝牙设备',
  58 + classic: '经典',
  59 + ble: '低功耗',
  60 + dual: '双模',
56 61 printer1: { name: '厨房打印机 #1', location: '主厨房' },
57 62 printer2: { name: '厨房打印机 #2', location: '主厨房' },
58 63 printer3: { name: '准备区打印机', location: '准备站' },
... ...
美国版/Food Labeling Management App UniApp/src/manifest.json
... ... @@ -2,8 +2,8 @@
2 2 "name" : "food.labeling",
3 3 "appid" : "__UNI__1BFD76D",
4 4 "description" : "",
5   - "versionName" : "1.0.1",
6   - "versionCode" : 101,
  5 + "versionName" : "1.0.3",
  6 + "versionCode" : 103,
7 7 "transformPx" : false,
8 8 /* 5+App特有相关 */
9 9 "app-plus" : {
... ... @@ -18,8 +18,9 @@
18 18 },
19 19 /* 模块配置 */
20 20 "modules" : {
21   - "Bluetooth" : {},
22   - "Camera" : {}
  21 + "Camera" : {},
  22 + "Barcode" : {},
  23 + "Bluetooth" : {}
23 24 },
24 25 /* 应用发布信息 */
25 26 "distribute" : {
... ... @@ -43,6 +44,10 @@
43 44 "<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>",
44 45 "<uses-permission android:name=\"android.permission.BLUETOOTH\"/>",
45 46 "<uses-permission android:name=\"android.permission.BLUETOOTH_ADMIN\"/>",
  47 + "<uses-permission android:name=\"android.permission.BLUETOOTH_SCAN\"/>",
  48 + "<uses-permission android:name=\"android.permission.BLUETOOTH_CONNECT\"/>",
  49 + "<uses-permission android:name=\"android.permission.BLUETOOTH_ADVERTISE\"/>",
  50 + "<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>",
46 51 "<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>"
47 52 ]
48 53 },
... ...
美国版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue
... ... @@ -42,6 +42,20 @@
42 42 <text class="error-text">{{ errorMsg }}</text>
43 43 </view>
44 44  
  45 + <view class="debug-card">
  46 + <text class="debug-title">Debug Status</text>
  47 + <text class="debug-item">Current Mode: {{ debugInfo.currentMode }}</text>
  48 + <text class="debug-item">Classic Module: {{ debugInfo.classicModuleReady ? 'Ready' : 'Not Ready' }}</text>
  49 + <text class="debug-item">Paired Count: {{ debugInfo.pairedCount }}</text>
  50 + <text class="debug-item">Virtual BT Printer: {{ debugInfo.foundVirtualPrinter ? 'Found' : 'Not Found' }}</text>
  51 + <text class="debug-item">Classic Scan: {{ debugInfo.lastClassicEvent }}</text>
  52 + <text class="debug-item">BLE Scan: {{ debugInfo.lastBleEvent }}</text>
  53 + <text v-if="debugInfo.lastBleError" class="debug-item debug-error">{{ debugInfo.lastBleError }}</text>
  54 + <text v-if="debugInfo.locationServiceRequired" class="debug-item debug-warn">
  55 + Android system Location service is OFF. Turn it on before BLE scan.
  56 + </text>
  57 + </view>
  58 +
45 59 <!-- Connected -->
46 60 <view v-if="connectedDevice" class="connected-card">
47 61 <view class="connected-header">
... ... @@ -153,26 +167,21 @@ import AppIcon from &#39;../../components/AppIcon.vue&#39;
153 167 import SideMenu from '../../components/SideMenu.vue'
154 168 import LocationPicker from '../../components/LocationPicker.vue'
155 169 import { getStatusBarHeight } from '../../utils/statusBar'
  170 +import classicBluetooth from '../../utils/print/bluetoothTool.js'
156 171 import {
157 172 getPrinterType,
158   - getBluetoothConnection,
159   - setBluetoothConnection,
160   - setBuiltinPrinter,
161 173 clearPrinter,
162   - sendToPrinter,
163 174 type PrinterType,
164 175 } from '../../utils/print/printerConnection'
165   -import { buildTestTscLabel } from '../../utils/print/tscLabelBuilder'
166   -
167   -function getClassicBluetooth () {
168   - try {
169   - // @ts-ignore - dynamic require for native plugin
170   - const m = require('../../utils/print/bluetoothTool.js')
171   - return m?.default ?? null
172   - } catch {
173   - return null
174   - }
175   -}
  176 +import { ensureBluetoothPermissions } from '../../utils/print/bluetoothPermissions'
  177 +import {
  178 + connectBluetoothPrinter,
  179 + describeDiscoveredPrinter,
  180 + disconnectCurrentPrinter,
  181 + getCurrentPrinterSummary,
  182 + testPrintCurrentPrinter,
  183 + useBuiltinPrinter,
  184 +} from '../../utils/print/manager/printerManager'
176 185  
177 186 const statusBarHeight = getStatusBarHeight()
178 187 const isMenuOpen = ref(false)
... ... @@ -181,6 +190,17 @@ const connectingId = ref(&#39;&#39;)
181 190 const errorMsg = ref('')
182 191 const btAdapterReady = ref(false)
183 192 const printerType = ref<PrinterType | ''>(getPrinterType() || 'bluetooth')
  193 +const currentPrinter = ref(getCurrentPrinterSummary())
  194 +const debugInfo = ref({
  195 + currentMode: 'none',
  196 + classicModuleReady: false,
  197 + pairedCount: 0,
  198 + foundVirtualPrinter: false,
  199 + lastClassicEvent: 'idle',
  200 + lastBleEvent: 'idle',
  201 + lastBleError: '',
  202 + locationServiceRequired: false,
  203 +})
184 204  
185 205 interface BtDevice {
186 206 deviceId: string
... ... @@ -192,14 +212,26 @@ interface BtDevice {
192 212 const devices = ref<BtDevice[]>([])
193 213 const discoveredIds = new Set<string>()
194 214  
  215 +function refreshCurrentPrinter () {
  216 + currentPrinter.value = getCurrentPrinterSummary()
  217 + debugInfo.value.currentMode = currentPrinter.value.type || 'none'
  218 +}
  219 +
  220 +function hasPreferredClassicDeviceInList () {
  221 + return devices.value.some((item: any) => {
  222 + const name = String(item?.name || '').toLowerCase()
  223 + const type = String(item?.type || '').toLowerCase()
  224 + return name.includes('virtual bt printer') || type === 'classic' || type === 'dual'
  225 + })
  226 +}
  227 +
195 228 const connectedDevice = computed(() => {
196   - const type = getPrinterType()
197   - if (printerType.value === 'builtin' && type === 'builtin') {
198   - return { name: 'Built-in Printer', deviceId: 'builtin' }
  229 + const summary = currentPrinter.value
  230 + if (printerType.value === 'builtin' && summary.type === 'builtin') {
  231 + return { name: summary.displayName, deviceId: summary.deviceId }
199 232 }
200   - if (printerType.value === 'bluetooth' && type === 'bluetooth') {
201   - const conn = getBluetoothConnection()
202   - if (conn) return { name: conn.deviceName, deviceId: conn.deviceId }
  233 + if (printerType.value === 'bluetooth' && summary.type === 'bluetooth') {
  234 + return { name: summary.displayName, deviceId: summary.deviceId }
203 235 }
204 236 return null
205 237 })
... ... @@ -208,6 +240,7 @@ function switchType (type: &#39;bluetooth&#39; | &#39;builtin&#39;) {
208 240 printerType.value = type
209 241 if (type === 'bluetooth' && getPrinterType() === 'builtin') {
210 242 clearPrinter()
  243 + refreshCurrentPrinter()
211 244 }
212 245 }
213 246  
... ... @@ -234,37 +267,24 @@ const initBluetooth = (): Promise&lt;void&gt; =&gt; {
234 267 })
235 268 }
236 269  
237   -const startDiscovery = () => {
  270 +const startBleScan = () => {
238 271 uni.startBluetoothDevicesDiscovery({
239 272 allowDuplicatesKey: false,
240 273 success: () => {
241 274 isScanning.value = true
242 275 errorMsg.value = ''
  276 + debugInfo.value.lastBleEvent = 'scan running'
243 277 },
244 278 fail: (err: any) => {
245   - isScanning.value = false
246   - errorMsg.value = 'Scan failed: ' + (err.errMsg || 'Unknown error')
  279 + console.error('BLE startBluetoothDevicesDiscovery fail:', err)
  280 + debugInfo.value.lastBleEvent = 'scan failed'
  281 + debugInfo.value.lastBleError = err?.errMsg || 'BLE scan failed'
  282 + if (err?.errCode === 10016 || err?.code === 10016) {
  283 + debugInfo.value.locationServiceRequired = true
  284 + errorMsg.value = 'Bluetooth scan failed: Android system Location service is turned off.'
  285 + }
247 286 },
248 287 })
249   - // 同时启动经典蓝牙扫描(发现 d320fax_295c 等未配对设备,不过滤任何设备)
250   - // #ifdef APP-PLUS
251   - const classic = getClassicBluetooth()
252   - if (classic && classic.startClassicDiscovery) {
253   - classic.startClassicDiscovery(
254   - (dev: { name: string; deviceId: string; type: string }) => {
255   - if (discoveredIds.has(dev.deviceId)) return
256   - discoveredIds.add(dev.deviceId)
257   - devices.value.push({
258   - deviceId: dev.deviceId,
259   - name: dev.name || 'Unknown',
260   - type: (dev.type as BtDevice['type']) || 'classic',
261   - })
262   - devices.value.sort((a, b) => (b.RSSI || -100) - (a.RSSI || -100))
263   - },
264   - () => { /* 经典蓝牙扫描完成 */ },
265   - )
266   - }
267   - // #endif
268 288 }
269 289  
270 290 const stopDiscovery = () => {
... ... @@ -274,7 +294,7 @@ const stopDiscovery = () =&gt; {
274 294 },
275 295 })
276 296 // #ifdef APP-PLUS
277   - const classic = getClassicBluetooth()
  297 + const classic = classicBluetooth
278 298 if (classic && classic.cancelClassicDiscovery) classic.cancelClassicDiscovery()
279 299 // #endif
280 300 }
... ... @@ -286,12 +306,12 @@ const onDeviceFound = (res: any) =&gt; {
286 306 const name = (d.localName || d.name || '').trim()
287 307 const displayName = name || 'Unknown Device'
288 308 discoveredIds.add(d.deviceId)
289   - devices.value.push({
  309 + devices.value.push(describeDiscoveredPrinter({
290 310 deviceId: d.deviceId,
291 311 name: displayName,
292 312 RSSI: d.RSSI,
293 313 type: 'ble',
294   - })
  314 + }))
295 315 }
296 316 devices.value.sort((a, b) => (b.RSSI || -100) - (a.RSSI || -100))
297 317 }
... ... @@ -305,12 +325,12 @@ function mergeCachedBleDevices () {
305 325 if (discoveredIds.has(d.deviceId)) continue
306 326 const name = (d.localName || d.name || '').trim()
307 327 discoveredIds.add(d.deviceId)
308   - devices.value.push({
  328 + devices.value.push(describeDiscoveredPrinter({
309 329 deviceId: d.deviceId,
310 330 name: name || 'Unknown Device',
311 331 RSSI: d.RSSI,
312 332 type: 'ble',
313   - })
  333 + }))
314 334 }
315 335 if (list.length > 0) devices.value.sort((a, b) => (b.RSSI || -100) - (a.RSSI || -100))
316 336 },
... ... @@ -319,60 +339,65 @@ function mergeCachedBleDevices () {
319 339  
320 340 function addPairedDevices () {
321 341 // #ifdef APP-PLUS
322   - const classic = getClassicBluetooth()
  342 + const classic = classicBluetooth
323 343 if (!classic || !classic.getPairedDevices) return
324 344 try {
325 345 const paired = classic.getPairedDevices()
  346 + debugInfo.value.pairedCount = (paired || []).length
  347 + debugInfo.value.foundVirtualPrinter = (paired || []).some((item: any) => String(item?.name || '').toLowerCase().includes('virtual bt printer'))
326 348 for (const p of paired) {
327 349 if (discoveredIds.has(p.deviceId)) continue
328 350 discoveredIds.add(p.deviceId)
329   - devices.value.push({
  351 + devices.value.push(describeDiscoveredPrinter({
330 352 deviceId: p.deviceId,
331   - name: p.name || 'Unknown',
  353 + name: p.name || 'Unknown Device',
332 354 type: p.type || 'classic',
333   - })
  355 + }))
334 356 }
335 357 if (paired.length > 0) {
  358 + debugInfo.value.lastClassicEvent = 'paired devices loaded'
336 359 devices.value.sort((a, b) => (b.RSSI || -100) - (a.RSSI || -100))
  360 + } else {
  361 + debugInfo.value.lastClassicEvent = 'no paired devices'
337 362 }
338 363 } catch (e) {
339 364 console.error('addPairedDevices error:', e)
  365 + debugInfo.value.lastClassicEvent = 'load paired devices failed'
340 366 }
341 367 // #endif
342 368 }
343 369  
344   -function findWriteCharacteristic (deviceId: string): Promise<{ serviceId: string; characteristicId: string } | null> {
345   - return new Promise((resolve) => {
346   - uni.getBLEDeviceServices({
347   - deviceId,
348   - success: (sres) => {
349   - const services = sres.services || []
350   - const tryNext = (idx: number) => {
351   - if (idx >= services.length) {
352   - resolve(null)
353   - return
354   - }
355   - const serviceId = services[idx].uuid
356   - uni.getBLEDeviceCharacteristics({
357   - deviceId,
358   - serviceId,
359   - success: (cres) => {
360   - const chars = cres.characteristics || []
361   - const writeChar = chars.find((c: any) => c.properties && c.properties.write)
362   - if (writeChar) {
363   - resolve({ serviceId, characteristicId: writeChar.uuid })
364   - return
365   - }
366   - tryNext(idx + 1)
367   - },
368   - fail: () => tryNext(idx + 1),
369   - })
370   - }
371   - tryNext(0)
372   - },
373   - fail: () => resolve(null),
374   - })
375   - })
  370 +function startClassicScan () {
  371 + // #ifdef APP-PLUS
  372 + const classic = classicBluetooth
  373 + if (classic && classic.startClassicDiscovery) {
  374 + try {
  375 + classic.startClassicDiscovery(
  376 + (dev: { name: string; deviceId: string; type: string }) => {
  377 + debugInfo.value.lastClassicEvent = 'device found'
  378 + if (discoveredIds.has(dev.deviceId)) return
  379 + discoveredIds.add(dev.deviceId)
  380 + devices.value.push(describeDiscoveredPrinter({
  381 + deviceId: dev.deviceId,
  382 + name: dev.name || 'Unknown Device',
  383 + type: (dev.type as BtDevice['type']) || 'classic',
  384 + }))
  385 + devices.value.sort((a, b) => (b.RSSI || -100) - (a.RSSI || -100))
  386 + },
  387 + () => {
  388 + debugInfo.value.lastClassicEvent = 'scan finished'
  389 + },
  390 + )
  391 + isScanning.value = true
  392 + debugInfo.value.lastClassicEvent = 'scan running'
  393 + } catch (e) {
  394 + console.error('Classic discovery failed', e)
  395 + debugInfo.value.lastClassicEvent = 'scan failed'
  396 + }
  397 + } else {
  398 + debugInfo.value.lastClassicEvent = 'classic module unavailable'
  399 + }
  400 + // #endif
376 401 }
377 402  
378 403 const handleScan = async () => {
... ... @@ -383,15 +408,30 @@ const handleScan = async () =&gt; {
383 408 errorMsg.value = ''
384 409 devices.value = []
385 410 discoveredIds.clear()
  411 + debugInfo.value.lastBleError = ''
  412 + debugInfo.value.locationServiceRequired = false
  413 + debugInfo.value.lastClassicEvent = 'starting'
  414 + debugInfo.value.lastBleEvent = 'starting'
  415 +
386 416 try {
387   - if (!btAdapterReady.value) {
388   - await initBluetooth()
  417 + const permissionResult = await ensureBluetoothPermissions({ scan: true, connect: true })
  418 + if (!permissionResult.ok) {
  419 + errorMsg.value = permissionResult.message || 'Bluetooth permission denied.'
  420 + return
389 421 }
  422 + await initBluetooth()
  423 +
390 424 addPairedDevices()
391   - mergeCachedBleDevices()
392   - if (devices.value.length > 0) {
393   - uni.showToast({ title: `Found ${devices.value.length} device(s)`, icon: 'none' })
  425 + startClassicScan()
  426 +
  427 + if (hasPreferredClassicDeviceInList()) {
  428 + isScanning.value = false
  429 + debugInfo.value.lastBleEvent = 'skipped (paired classic device found)'
  430 + uni.showToast({ title: 'Using paired classic Bluetooth devices', icon: 'none' })
  431 + return
394 432 }
  433 +
  434 + mergeCachedBleDevices()
395 435 await new Promise<void>((resolve) => {
396 436 uni.getLocation({
397 437 type: 'wgs84',
... ... @@ -399,13 +439,15 @@ const handleScan = async () =&gt; {
399 439 fail: () => resolve(),
400 440 })
401 441 })
402   - startDiscovery()
  442 + startBleScan()
403 443 uni.showToast({ title: 'Scanning...', icon: 'none' })
404 444 setTimeout(() => {
405 445 if (isScanning.value) stopDiscovery()
406 446 }, 20000)
407 447 } catch (_) {
408   - // error set in initBluetooth
  448 + if (!isScanning.value && devices.value.length === 0) {
  449 + errorMsg.value = 'Bluetooth scan failed. Check if Bluetooth is enabled.'
  450 + }
409 451 }
410 452 }
411 453  
... ... @@ -416,77 +458,27 @@ const handleConnect = async (dev: BtDevice) =&gt; {
416 458  
417 459 if (isScanning.value) stopDiscovery()
418 460  
419   - // 经典蓝牙:classic、dual、unknown(部分设备如 D320FAX 可能误报为 unknown,也尝试经典连接)
420   - const useClassic = dev.type === 'classic' || dev.type === 'dual' || dev.type === 'unknown'
421   -
422   - if (useClassic) {
423   - // #ifdef APP-PLUS
424   - const classic = getClassicBluetooth()
425   - if (classic && classic.connDevice) {
426   - classic.connDevice(dev.deviceId, (ok: boolean) => {
427   - connectingId.value = ''
428   - if (ok) {
429   - setBluetoothConnection({
430   - deviceId: dev.deviceId,
431   - deviceName: dev.name,
432   - deviceType: 'classic',
433   - })
434   - uni.showToast({ title: 'Connected!', icon: 'success' })
435   - } else {
436   - errorMsg.value = 'Connection failed. For D320FAX, ensure Virtual BT Printer is paired in system Bluetooth.'
437   - }
438   - })
439   - } else {
440   - connectingId.value = ''
441   - errorMsg.value = 'Classic Bluetooth not available. Ensure app is running on the device (not simulator).'
442   - }
443   - // #endif
444   - // #ifndef APP-PLUS
  461 + const permissionResult = await ensureBluetoothPermissions({ connect: true })
  462 + if (!permissionResult.ok) {
445 463 connectingId.value = ''
446   - errorMsg.value = 'Classic Bluetooth requires the app.'
447   - // #endif
  464 + errorMsg.value = permissionResult.message || 'Bluetooth permission denied.'
448 465 return
449 466 }
450 467  
451   - uni.createBLEConnection({
452   - deviceId: dev.deviceId,
453   - timeout: 10000,
454   - success: async () => {
455   - try {
456   - const write = await findWriteCharacteristic(dev.deviceId)
457   - if (!write) {
458   - errorMsg.value = 'No writable characteristic found. This device may not support printing.'
459   - connectingId.value = ''
460   - return
461   - }
462   - setBluetoothConnection({
463   - deviceId: dev.deviceId,
464   - deviceName: dev.name,
465   - serviceId: write.serviceId,
466   - characteristicId: write.characteristicId,
467   - deviceType: 'ble',
468   - mtu: 20,
469   - })
470   - connectingId.value = ''
471   - uni.showToast({ title: 'Connected!', icon: 'success' })
472   - } catch (e: any) {
473   - errorMsg.value = (e && e.message) ? e.message : 'Connection failed'
474   - connectingId.value = ''
475   - }
476   - },
477   - fail: (err: any) => {
478   - connectingId.value = ''
479   - if (err.errCode === -1) {
480   - uni.showToast({ title: 'Already connected', icon: 'success' })
481   - } else {
482   - errorMsg.value = 'Connection failed: ' + (err.errMsg || 'Try again')
483   - }
484   - },
485   - })
  468 + try {
  469 + await connectBluetoothPrinter(dev)
  470 + refreshCurrentPrinter()
  471 + connectingId.value = ''
  472 + uni.showToast({ title: 'Connected!', icon: 'success' })
  473 + } catch (e: any) {
  474 + errorMsg.value = (e && e.message) ? e.message : 'Connection failed'
  475 + connectingId.value = ''
  476 + }
486 477 }
487 478  
488 479 const handleUseBuiltin = () => {
489   - setBuiltinPrinter()
  480 + useBuiltinPrinter()
  481 + refreshCurrentPrinter()
490 482 uni.showToast({ title: 'Using built-in printer', icon: 'success' })
491 483 }
492 484  
... ... @@ -495,8 +487,7 @@ const handleTestPrint = async () =&gt; {
495 487 if (testPrinting.value) return
496 488 testPrinting.value = true
497 489 try {
498   - const data = buildTestTscLabel()
499   - await sendToPrinter(data, (p) => {
  490 + await testPrintCurrentPrinter((p) => {
500 491 if (p < 100) uni.showLoading({ title: `Printing ${p}%`, mask: true })
501 492 })
502 493 uni.hideLoading()
... ... @@ -523,30 +514,14 @@ const handleTestPrint = async () =&gt; {
523 514 }
524 515 }
525 516  
526   -const handleDisconnect = () => {
527   - const type = getPrinterType()
528   - const deviceId = uni.getStorageSync('btDeviceId')
529   - const deviceType = uni.getStorageSync('btDeviceType')
530   - if (type === 'bluetooth' && deviceType === 'classic') {
531   - // #ifdef APP-PLUS
532   - const classic = getClassicBluetooth()
533   - if (classic && classic.disConnDevice) classic.disConnDevice()
534   - // #endif
535   - }
536   - clearPrinter()
537   - if (type === 'bluetooth' && deviceId && deviceType !== 'classic') {
538   - uni.closeBLEConnection({
539   - deviceId,
540   - complete: () => {
541   - uni.showToast({ title: 'Disconnected', icon: 'none' })
542   - },
543   - })
544   - } else {
545   - uni.showToast({ title: 'Disconnected', icon: 'none' })
546   - }
  517 +const handleDisconnect = async () => {
  518 + await disconnectCurrentPrinter()
  519 + refreshCurrentPrinter()
  520 + uni.showToast({ title: 'Disconnected', icon: 'none' })
547 521 }
548 522  
549 523 onMounted(() => {
  524 + debugInfo.value.classicModuleReady = !!classicBluetooth
550 525 uni.onBluetoothDeviceFound(onDeviceFound)
551 526 uni.onBluetoothAdapterStateChange((res: any) => {
552 527 if (!res.available) {
... ... @@ -559,11 +534,22 @@ onMounted(() =&gt; {
559 534 }
560 535 })
561 536 printerType.value = getPrinterType() || 'bluetooth'
  537 + refreshCurrentPrinter()
562 538 })
563 539  
564 540 onUnmounted(() => {
565 541 if (isScanning.value) stopDiscovery()
566   - uni.offBluetoothDeviceFound()
  542 + try {
  543 + if (typeof uni.offBluetoothDeviceFound === 'function') {
  544 + uni.offBluetoothDeviceFound()
  545 + }
  546 + } catch (_) {}
  547 + try {
  548 + if (typeof uni.offBluetoothAdapterStateChange === 'function') {
  549 + uni.offBluetoothAdapterStateChange()
  550 + }
  551 + } catch (_) {}
  552 + uni.closeBluetoothAdapter({ complete: () => {} })
567 553 })
568 554 </script>
569 555  
... ... @@ -883,6 +869,37 @@ onUnmounted(() =&gt; {
883 869 gap: 12rpx;
884 870 }
885 871  
  872 +.debug-card {
  873 + background: #fff7ed;
  874 + border: 1rpx solid #fed7aa;
  875 + border-radius: 20rpx;
  876 + padding: 24rpx;
  877 + margin-bottom: 24rpx;
  878 + display: flex;
  879 + flex-direction: column;
  880 + gap: 8rpx;
  881 +}
  882 +
  883 +.debug-title {
  884 + font-size: 28rpx;
  885 + font-weight: 700;
  886 + color: #9a3412;
  887 +}
  888 +
  889 +.debug-item {
  890 + font-size: 22rpx;
  891 + color: #7c2d12;
  892 +}
  893 +
  894 +.debug-error {
  895 + color: #b91c1c;
  896 +}
  897 +
  898 +.debug-warn {
  899 + color: #92400e;
  900 + font-weight: 600;
  901 +}
  902 +
886 903 .device-tag {
887 904 display: inline-block;
888 905 font-size: 20rpx;
... ...
美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
... ... @@ -118,8 +118,7 @@ import SideMenu from &#39;../../components/SideMenu.vue&#39;
118 118 import LocationPicker from '../../components/LocationPicker.vue'
119 119 import { getStatusBarHeight } from '../../utils/statusBar'
120 120 import { generateNextLabelId } from '../../utils/printLog'
121   -import { getPrinterType, getBluetoothConnection, sendToPrinter } from '../../utils/print/printerConnection'
122   -import { buildTscLabel } from '../../utils/print/tscLabelBuilder'
  121 +import { getCurrentPrinterSummary, printLabelForCurrentPrinter } from '../../utils/print/manager/printerManager'
123 122 import chickenLabelImg from '../../static/chicken-lable.png'
124 123  
125 124 const statusBarHeight = getStatusBarHeight()
... ... @@ -161,16 +160,9 @@ const displayProductName = computed(() =&gt; {
161 160 })
162 161  
163 162 onShow(() => {
164   - const type = getPrinterType()
165   - const conn = type === 'bluetooth' ? getBluetoothConnection() : null
166   - btConnected.value = (type === 'bluetooth' && conn) || type === 'builtin'
167   - if (type === 'builtin') {
168   - btDeviceName.value = 'Built-in Printer'
169   - } else if (conn) {
170   - btDeviceName.value = conn.deviceName || ''
171   - } else {
172   - btDeviceName.value = ''
173   - }
  163 + const summary = getCurrentPrinterSummary()
  164 + btConnected.value = summary.type === 'bluetooth' || summary.type === 'builtin'
  165 + btDeviceName.value = summary.displayName || ''
174 166 })
175 167  
176 168 interface ProductData {
... ... @@ -245,13 +237,12 @@ const handlePrint = async () =&gt; {
245 237 }
246 238 isPrinting.value = true
247 239 try {
248   - const data = buildTscLabel({
  240 + await printLabelForCurrentPrinter({
249 241 productName: displayProductName.value,
250 242 labelId: labelId.value,
251 243 printQty: printQty.value,
252 244 extraLine: lastEdited.value,
253   - })
254   - await sendToPrinter(data, (percent) => {
  245 + }, (percent) => {
255 246 if (percent >= 100) return
256 247 uni.showLoading({ title: `Printing ${percent}%`, mask: true })
257 248 })
... ... @@ -265,8 +256,8 @@ const handlePrint = async () =&gt; {
265 256 const msg = (e && e.message) ? e.message : 'Print failed'
266 257 if (msg === 'BUILTIN_PLUGIN_NOT_FOUND' || (msg && msg.indexOf('Built-in printer') !== -1)) {
267 258 uni.showModal({
268   - title: 'Use Bluetooth Mode',
269   - content: 'For GP-D320FAX, go to Printer settings, switch to Bluetooth mode and tap Scan to connect (e.g. d320fax_295c or Virtual BT Printer). Built-in mode needs custom app packaging.',
  259 + title: 'Built-in Print Not Available',
  260 + content: 'This device does not support TCP built-in printing. Please switch to Bluetooth mode: go to Printer Settings, tap Scan, and connect to your printer (look for a device name like "Virtual BT Printer" or your printer model). You may need to pair it first in Android Bluetooth settings.',
270 261 confirmText: 'Printer Settings',
271 262 cancelText: 'Cancel',
272 263 success: (res) => {
... ...
美国版/Food Labeling Management App UniApp/src/pages/more/printers.vue
... ... @@ -21,6 +21,20 @@
21 21 <text class="badge-text">{{ t('printers.connected') }}</text>
22 22 </view>
23 23  
  24 + <view class="debug-card">
  25 + <text class="debug-title">Debug Status</text>
  26 + <text class="debug-item">Current Mode: {{ debugInfo.currentMode }}</text>
  27 + <text class="debug-item">Classic Module: {{ debugInfo.classicModuleReady ? 'Ready' : 'Not Ready' }}</text>
  28 + <text class="debug-item">Paired Count: {{ debugInfo.pairedCount }}</text>
  29 + <text class="debug-item">Virtual BT Printer: {{ debugInfo.foundVirtualPrinter ? 'Found' : 'Not Found' }}</text>
  30 + <text class="debug-item">Classic Scan: {{ debugInfo.lastClassicEvent }}</text>
  31 + <text class="debug-item">BLE Scan: {{ debugInfo.lastBleEvent }}</text>
  32 + <text v-if="debugInfo.lastBleError" class="debug-item debug-error">{{ debugInfo.lastBleError }}</text>
  33 + <text v-if="debugInfo.locationServiceRequired" class="debug-item debug-warn">
  34 + Android system Location service is OFF. Turn it on in device settings before BLE scan.
  35 + </text>
  36 + </view>
  37 +
24 38 <!-- Connected Device -->
25 39 <view v-if="currentType" class="printer-card connected">
26 40 <view class="printer-icon"><AppIcon name="printer" size="md" color="white" /></view>
... ... @@ -45,6 +59,29 @@
45 59 <button class="btn-connect">{{ t('printers.connect') }}</button>
46 60 </view>
47 61  
  62 + <!-- Paired Devices -->
  63 + <!-- #ifdef APP-PLUS -->
  64 + <view class="section-header">
  65 + <text class="section-title">{{ t('printers.pairedDevices') }}</text>
  66 + </view>
  67 +
  68 + <view v-for="d in pairedDevices" :key="'paired-' + d.deviceId" class="printer-card device-item" @click="connectBt(d)">
  69 + <view class="printer-icon"><AppIcon name="bluetooth" size="md" color="blue" /></view>
  70 + <view class="printer-info">
  71 + <text class="printer-name">{{ d.name || 'Unknown Device' }}</text>
  72 + <text class="printer-loc">{{ d.deviceId }}</text>
  73 + </view>
  74 + <view class="device-meta">
  75 + <text class="device-type-tag" :class="'tag-' + (d.type || 'ble')">{{ getTypeLabel(d.type) }}</text>
  76 + <button class="btn-connect" :disabled="isConnecting">{{ t('printers.connect') }}</button>
  77 + </view>
  78 + </view>
  79 +
  80 + <view v-if="pairedDevices.length === 0" class="empty-state">
  81 + <text class="empty-text">{{ t('printers.noPairedDevices') }}</text>
  82 + </view>
  83 + <!-- #endif -->
  84 +
48 85 <!-- Bluetooth Scanning Area -->
49 86 <view class="section-header">
50 87 <text class="section-title">{{ t('printers.nearby') }}</text>
... ... @@ -62,7 +99,10 @@
62 99 <text class="printer-name">{{ d.name || 'Unknown Device' }}</text>
63 100 <text class="printer-loc">{{ d.deviceId }}</text>
64 101 </view>
65   - <button class="btn-connect" :disabled="isConnecting">{{ t('printers.connect') }}</button>
  102 + <view class="device-meta">
  103 + <text v-if="d.type && d.type !== 'unknown'" class="device-type-tag" :class="'tag-' + d.type">{{ getTypeLabel(d.type) }}</text>
  104 + <button class="btn-connect" :disabled="isConnecting">{{ t('printers.connect') }}</button>
  105 + </view>
66 106 </view>
67 107  
68 108 <view v-if="devices.length === 0 && !isScanning" class="empty-state">
... ... @@ -79,178 +119,290 @@ import { ref, onMounted, onUnmounted } from &#39;vue&#39;
79 119 import { useI18n } from 'vue-i18n'
80 120 import AppIcon from '../../components/AppIcon.vue'
81 121 import SideMenu from '../../components/SideMenu.vue'
  122 +import classicBluetooth from '../../utils/print/bluetoothTool.js'
  123 +import { ensureBluetoothPermissions } from '../../utils/print/bluetoothPermissions'
82 124 import {
83   - getPrinterType,
84   - getBluetoothConnection,
85   - setBluetoothConnection,
86   - setBuiltinPrinter,
87   - clearPrinter,
88   - sendToPrinter,
89   - isBuiltinConnected
90   -} from '../../utils/print/printerConnection'
91   -import { buildTestTscLabel } from '../../utils/print/tscLabelBuilder'
  125 + connectBluetoothPrinter,
  126 + describeDiscoveredPrinter,
  127 + disconnectCurrentPrinter,
  128 + getCurrentPrinterSummary,
  129 + testPrintCurrentPrinter,
  130 + useBuiltinPrinter,
  131 +} from '../../utils/print/manager/printerManager'
92 132  
93 133 const { t } = useI18n()
94 134 const isMenuOpen = ref(false)
95 135  
96   -// 状态管理
97   -const currentType = ref(getPrinterType())
98   -const currentBt = ref(getBluetoothConnection())
  136 +const currentType = ref<'' | 'bluetooth' | 'builtin'>('')
  137 +const currentBt = ref<any>(null)
99 138 const isScanning = ref(false)
100 139 const devices = ref<any[]>([])
  140 +const pairedDevices = ref<any[]>([])
101 141 const isConnecting = ref(false)
  142 +const debugInfo = ref({
  143 + currentMode: 'none',
  144 + classicModuleReady: false,
  145 + pairedCount: 0,
  146 + foundVirtualPrinter: false,
  147 + lastClassicEvent: 'idle',
  148 + lastBleEvent: 'idle',
  149 + lastBleError: '',
  150 + locationServiceRequired: false,
  151 +})
  152 +let bleListenerRegistered = false
102 153  
103   -// Refresh current state
104 154 const refreshStatus = () => {
105   - currentType.value = getPrinterType()
106   - currentBt.value = getBluetoothConnection()
  155 + const summary = getCurrentPrinterSummary()
  156 + currentType.value = summary.type
  157 + debugInfo.value.currentMode = summary.type || 'none'
  158 + currentBt.value = summary.type === 'bluetooth'
  159 + ? {
  160 + deviceName: summary.displayName,
  161 + deviceId: summary.deviceId,
  162 + deviceType: summary.deviceType,
  163 + driverName: summary.driverName,
  164 + protocol: summary.protocol,
  165 + }
  166 + : null
  167 +}
  168 +
  169 +const getTypeLabel = (type?: string) => {
  170 + switch (type) {
  171 + case 'classic': return t('printers.classic')
  172 + case 'ble': return t('printers.ble')
  173 + case 'dual': return t('printers.dual')
  174 + default: return t('printers.ble')
  175 + }
  176 +}
  177 +
  178 +const normalizeDeviceName = (device: any) => {
  179 + return (device?.localName || device?.name || '').trim() || 'Unknown Device'
  180 +}
  181 +
  182 +const hasPreferredClassicDevice = () => {
  183 + return pairedDevices.value.some((item: any) => {
  184 + const name = String(item?.name || '').toLowerCase()
  185 + const type = String(item?.type || '').toLowerCase()
  186 + return name.includes('virtual bt printer') || type === 'classic' || type === 'dual'
  187 + })
  188 +}
  189 +
  190 +const addDeviceDedup = (device: any) => {
  191 + const described = describeDiscoveredPrinter(device)
  192 + const existing = devices.value.find(d => d.deviceId === device.deviceId)
  193 + if (!existing) {
  194 + devices.value.push(described)
  195 + return
  196 + }
  197 + Object.assign(existing, described)
  198 +}
  199 +
  200 +// #ifdef APP-PLUS
  201 +const loadPairedDevices = () => {
  202 + try {
  203 + const list = classicBluetooth.getPairedDevices()
  204 + debugInfo.value.pairedCount = (list || []).length
  205 + debugInfo.value.foundVirtualPrinter = (list || []).some((item: any) => String(item?.name || '').toLowerCase().includes('virtual bt printer'))
  206 + pairedDevices.value = (list || []).map((item: any) => ({
  207 + ...describeDiscoveredPrinter(item),
  208 + name: normalizeDeviceName(item),
  209 + }))
  210 + debugInfo.value.lastClassicEvent = pairedDevices.value.length > 0 ? 'paired devices loaded' : 'no paired devices'
  211 + } catch (e) {
  212 + console.error('Failed to load paired devices', e)
  213 + pairedDevices.value = []
  214 + debugInfo.value.pairedCount = 0
  215 + debugInfo.value.foundVirtualPrinter = false
  216 + debugInfo.value.lastClassicEvent = 'load paired devices failed'
  217 + }
107 218 }
  219 +// #endif
108 220  
109   -// 蓝牙搜索
110   -const startScan = () => {
  221 +function mergeCachedBleDevices () {
  222 + uni.getBluetoothDevices({
  223 + success: (res: any) => {
  224 + const list = res.devices || []
  225 + list.forEach((device: any) => {
  226 + addDeviceDedup({
  227 + ...device,
  228 + name: normalizeDeviceName(device),
  229 + type: device.type || 'ble',
  230 + })
  231 + })
  232 + },
  233 + })
  234 +}
  235 +
  236 +const startScan = async () => {
111 237 if (isScanning.value) return
112   -
  238 +
113 239 devices.value = []
114   - isScanning.value = true
115   -
  240 + debugInfo.value.lastBleError = ''
  241 + debugInfo.value.locationServiceRequired = false
  242 + debugInfo.value.lastClassicEvent = 'starting'
  243 + debugInfo.value.lastBleEvent = 'starting'
  244 +
  245 + const permissionResult = await ensureBluetoothPermissions({ scan: true, connect: true })
  246 + if (!permissionResult.ok) {
  247 + uni.showToast({ title: permissionResult.message || t('printers.bleNotAvailable'), icon: 'none' })
  248 + return
  249 + }
  250 +
116 251 uni.openBluetoothAdapter({
117 252 success: () => {
  253 + // #ifdef APP-PLUS
  254 + loadPairedDevices()
  255 + try {
  256 + classicBluetooth.startClassicDiscovery(
  257 + (device: any) => {
  258 + debugInfo.value.lastClassicEvent = 'device found'
  259 + addDeviceDedup({
  260 + ...device,
  261 + name: normalizeDeviceName(device),
  262 + })
  263 + },
  264 + () => {
  265 + debugInfo.value.lastClassicEvent = 'scan finished'
  266 + }
  267 + )
  268 + debugInfo.value.lastClassicEvent = 'scan running'
  269 + } catch (e) {
  270 + console.error('Classic discovery failed', e)
  271 + debugInfo.value.lastClassicEvent = 'scan failed'
  272 + }
  273 + // #endif
  274 + isScanning.value = true
  275 +
  276 + if (hasPreferredClassicDevice()) {
  277 + debugInfo.value.lastBleEvent = 'skipped (paired classic device found)'
  278 + return
  279 + }
  280 +
  281 + mergeCachedBleDevices()
  282 + if (!bleListenerRegistered) {
  283 + bleListenerRegistered = true
  284 + uni.onBluetoothDeviceFound((res) => {
  285 + res.devices.forEach(device => {
  286 + addDeviceDedup({
  287 + ...device,
  288 + name: normalizeDeviceName(device),
  289 + type: 'ble',
  290 + })
  291 + })
  292 + })
  293 + }
118 294 uni.startBluetoothDevicesDiscovery({
119 295 allowDuplicatesKey: false,
120 296 success: () => {
121   - uni.onBluetoothDeviceFound((res) => {
122   - res.devices.forEach(device => {
123   - if (device.name && !devices.value.find(d => d.deviceId === device.deviceId)) {
124   - devices.value.push(device)
125   - }
126   - })
127   - })
  297 + debugInfo.value.lastBleEvent = 'scan running'
128 298 },
129 299 fail: (err) => {
130   - isScanning.value = false
131   - uni.showToast({ title: t('printers.bleNotAvailable'), icon: 'none' })
  300 + console.error('BLE startBluetoothDevicesDiscovery fail:', err)
  301 + debugInfo.value.lastBleEvent = 'scan failed'
  302 + debugInfo.value.lastBleError = err?.errMsg || 'BLE scan failed'
  303 + if (err?.errCode === 10016 || err?.code === 10016) {
  304 + debugInfo.value.locationServiceRequired = true
  305 + debugInfo.value.lastBleError = 'BLE scan failed: system Location service is turned off.'
  306 + uni.showToast({
  307 + title: 'Turn on system Location service first',
  308 + icon: 'none',
  309 + duration: 2500,
  310 + })
  311 + }
132 312 }
133 313 })
134 314 },
135 315 fail: (err) => {
136   - isScanning.value = false
137   - uni.showToast({ title: t('printers.bleNotAvailable'), icon: 'none' })
  316 + console.error('openBluetoothAdapter fail:', err)
  317 + if (devices.value.length === 0 && pairedDevices.value.length === 0) {
  318 + uni.showToast({ title: t('printers.bleNotAvailable'), icon: 'none' })
  319 + }
138 320 }
139 321 })
140 322 }
141 323  
142 324 const stopScan = () => {
143 325 isScanning.value = false
  326 + debugInfo.value.lastClassicEvent = 'stopped'
  327 + debugInfo.value.lastBleEvent = 'stopped'
144 328 uni.stopBluetoothDevicesDiscovery({
145   - success: () => console.log('Stop scan success'),
146   - fail: (err) => console.error('Stop scan fail', err)
  329 + success: () => console.log('Stop BLE scan success'),
  330 + fail: (err) => console.error('Stop BLE scan fail', err)
147 331 })
  332 + // #ifdef APP-PLUS
  333 + try {
  334 + classicBluetooth.cancelClassicDiscovery()
  335 + } catch (e) {
  336 + console.error('Cancel classic discovery failed', e)
  337 + }
  338 + // #endif
148 339 }
149 340  
150   -// 连接蓝牙设备
151   -const connectBt = (device: any) => {
  341 +const connectBt = async (device: any) => {
152 342 if (isConnecting.value) return
153 343 isConnecting.value = true
154 344 stopScan()
155   -
156   - uni.showLoading({ title: t('printers.connecting') })
157   -
158   - uni.createBLEConnection({
159   - deviceId: device.deviceId,
160   - success: () => {
161   - uni.getBLEDeviceServices({
162   - deviceId: device.deviceId,
163   - success: (res) => {
164   - // 这里简化处理:寻找第一个包含写权限的特征值
165   - // 实际项目中可能需要根据特定 UUID 匹配,官方示例中是遍历寻找
166   - findWriteCharacteristic(device.deviceId, res.services, device.name)
167   - },
168   - fail: (err) => {
169   - isConnecting.value = false
170   - uni.hideLoading()
171   - uni.showToast({ title: t('printers.connectFail'), icon: 'none' })
172   - }
173   - })
174   - },
175   - fail: (err) => {
176   - isConnecting.value = false
177   - uni.hideLoading()
178   - uni.showToast({ title: t('printers.connectFail'), icon: 'none' })
179   - }
180   - })
181   -}
182 345  
183   -const findWriteCharacteristic = (deviceId: string, services: any[], deviceName: string) => {
184   - let found = false
185   - let serviceIdx = 0
186   -
187   - const nextService = () => {
188   - if (serviceIdx >= services.length || found) {
189   - if (!found) {
190   - isConnecting.value = false
191   - uni.hideLoading()
192   - uni.showToast({ title: 'No write characteristic found', icon: 'none' })
193   - }
194   - return
195   - }
196   -
197   - const service = services[serviceIdx++]
198   - uni.getBLEDeviceCharacteristics({
199   - deviceId,
200   - serviceId: service.uuid,
201   - success: (res) => {
202   - const char = res.characteristics.find(c => c.properties.write)
203   - if (char) {
204   - found = true
205   - setBluetoothConnection({
206   - deviceId,
207   - deviceName,
208   - serviceId: service.uuid,
209   - characteristicId: char.uuid,
210   - deviceType: 'ble'
211   - })
212   - currentType.value = 'bluetooth'
213   - currentBt.value = getBluetoothConnection()
214   - isConnecting.value = false
215   - uni.hideLoading()
216   - uni.showToast({ title: t('printers.connectSuccess') })
217   - } else {
218   - nextService()
219   - }
220   - },
221   - fail: () => nextService()
222   - })
  346 + const permissionResult = await ensureBluetoothPermissions({ connect: true })
  347 + if (!permissionResult.ok) {
  348 + isConnecting.value = false
  349 + uni.showToast({ title: permissionResult.message || t('printers.connectFail'), icon: 'none' })
  350 + return
  351 + }
  352 +
  353 + uni.showLoading({ title: t('printers.connecting') })
  354 + try {
  355 + await connectBluetoothPrinter(device)
  356 + refreshStatus()
  357 + uni.hideLoading()
  358 + uni.showToast({ title: t('printers.connectSuccess') })
  359 + } catch (e) {
  360 + uni.hideLoading()
  361 + uni.showToast({ title: t('printers.connectFail'), icon: 'none' })
  362 + } finally {
  363 + isConnecting.value = false
223 364 }
224   -
225   - nextService()
226 365 }
227 366  
228   -// 连接内置打印机
229 367 const connectBuiltin = () => {
230   - setBuiltinPrinter()
231   - currentType.value = 'builtin'
  368 + useBuiltinPrinter()
  369 + refreshStatus()
232 370 uni.showToast({ title: t('printers.connectSuccess') })
233 371 }
234 372  
235   -// 断开连接
236   -const disconnect = () => {
237   - clearPrinter()
238   - currentType.value = ''
239   - currentBt.value = null
  373 +const disconnect = async () => {
  374 + await disconnectCurrentPrinter()
  375 + refreshStatus()
240 376 uni.showToast({ title: t('printers.disconnected') })
241 377 }
242 378  
243   -// 测试打印
244 379 const doTestPrint = async () => {
245 380 try {
246   - const data = buildTestTscLabel()
  381 + if (currentType.value === 'builtin') {
  382 + uni.showModal({
  383 + title: 'Current Mode is Built-in',
  384 + content: 'This handheld device is currently using Built-in mode. Please disconnect it, then connect "Virtual BT Printer" from the Paired Devices list and test print again.',
  385 + showCancel: false,
  386 + })
  387 + return
  388 + }
247 389 uni.showLoading({ title: t('labels.print.printing') })
248   - await sendToPrinter(data)
  390 + await testPrintCurrentPrinter()
249 391 uni.hideLoading()
250 392 uni.showToast({ title: t('printers.testPrintSuccess') })
251 393 } catch (e: any) {
252 394 uni.hideLoading()
253   - uni.showToast({ title: t('printers.testPrintFail') + ': ' + e.message, icon: 'none' })
  395 + const msg = (e && e.message) ? e.message : 'Print failed'
  396 + if (msg.indexOf('Built-in printer') !== -1 || msg === 'BUILTIN_PLUGIN_NOT_FOUND') {
  397 + uni.showModal({
  398 + title: 'Print Failed',
  399 + content: 'Built-in TCP printing is not available on this device. Please switch to Bluetooth mode and scan for your printer. Check Android Bluetooth settings to pair with "Virtual BT Printer" or your printer model first.',
  400 + confirmText: 'OK',
  401 + showCancel: false,
  402 + })
  403 + } else {
  404 + uni.showToast({ title: t('printers.testPrintFail') + ': ' + msg, icon: 'none' })
  405 + }
254 406 }
255 407 }
256 408  
... ... @@ -265,10 +417,23 @@ const goBack = () =&gt; {
265 417  
266 418 onMounted(() => {
267 419 refreshStatus()
  420 + debugInfo.value.classicModuleReady = !!classicBluetooth
  421 + // #ifdef APP-PLUS
  422 + loadPairedDevices()
  423 + // #endif
268 424 })
269 425  
270 426 onUnmounted(() => {
271 427 if (isScanning.value) stopScan()
  428 + if (bleListenerRegistered) {
  429 + bleListenerRegistered = false
  430 + try {
  431 + if (typeof uni.offBluetoothDeviceFound === 'function') {
  432 + uni.offBluetoothDeviceFound()
  433 + }
  434 + } catch (_) {}
  435 + }
  436 + uni.closeBluetoothAdapter({ complete: () => {} })
272 437 })
273 438 </script>
274 439  
... ... @@ -288,6 +453,11 @@ onUnmounted(() =&gt; {
288 453 .info-badge { display: flex; align-items: center; gap: 12rpx; margin-bottom: 32rpx; }
289 454 .badge-num { font-size: 40rpx; font-weight: 700; color: var(--theme-primary); }
290 455 .badge-text { font-size: 28rpx; color: #6b7280; }
  456 +.debug-card { background: #fff7ed; border: 1rpx solid #fed7aa; border-radius: 20rpx; padding: 24rpx; margin-bottom: 24rpx; display: flex; flex-direction: column; gap: 8rpx; }
  457 +.debug-title { font-size: 28rpx; font-weight: 700; color: #9a3412; }
  458 +.debug-item { font-size: 22rpx; color: #7c2d12; }
  459 +.debug-error { color: #b91c1c; }
  460 +.debug-warn { color: #92400e; font-weight: 600; }
291 461 .printer-card { background: #fff; padding: 32rpx; border-radius: 24rpx; margin-bottom: 24rpx; display: flex; align-items: center; gap: 24rpx; box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.05); border: 1rpx solid #f3f4f6; }
292 462 .printer-card.connected { background: var(--theme-primary); border: none; }
293 463 .printer-card.connected .printer-name, .printer-card.connected .printer-loc, .printer-card.connected .printer-status { color: #fff; }
... ... @@ -316,4 +486,10 @@ onUnmounted(() =&gt; {
316 486  
317 487 .empty-state { padding: 80rpx 0; text-align: center; }
318 488 .empty-text { font-size: 28rpx; color: #9ca3af; }
  489 +
  490 +.device-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 12rpx; flex-shrink: 0; }
  491 +.device-type-tag { display: inline-block; padding: 4rpx 16rpx; font-size: 20rpx; border-radius: 8rpx; font-weight: 500; }
  492 +.tag-ble { background: #eff6ff; color: #3b82f6; }
  493 +.tag-classic { background: #fef3c7; color: #d97706; }
  494 +.tag-dual { background: #ecfdf5; color: #059669; }
319 495 </style>
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/bluetoothPermissions.ts 0 → 100644
  1 +export interface BluetoothPermissionResult {
  2 + ok: boolean
  3 + message?: string
  4 +}
  5 +
  6 +function normalizePermissionName (permission: string): string {
  7 + return String(permission || '').split('.').pop() || String(permission || '')
  8 +}
  9 +
  10 +function formatDeniedMessage (permissions: string[]): string {
  11 + if (!permissions.length) return 'Bluetooth permission denied.'
  12 + return 'Please allow Bluetooth permissions: ' + permissions.map(normalizePermissionName).join(', ')
  13 +}
  14 +
  15 +function requestAndroidPermissions (permissions: string[]): Promise<BluetoothPermissionResult> {
  16 + return new Promise((resolve) => {
  17 + // #ifdef APP-PLUS
  18 + try {
  19 + if (typeof plus === 'undefined' || !plus.android || !permissions.length) {
  20 + resolve({ ok: true })
  21 + return
  22 + }
  23 + plus.android.requestPermissions(
  24 + permissions,
  25 + (resultObj: any) => {
  26 + const deniedPresent = Array.isArray(resultObj?.deniedPresent) ? resultObj.deniedPresent : []
  27 + const deniedAlways = Array.isArray(resultObj?.deniedAlways) ? resultObj.deniedAlways : []
  28 + const denied = [...deniedPresent, ...deniedAlways]
  29 + if (denied.length > 0) {
  30 + resolve({
  31 + ok: false,
  32 + message: formatDeniedMessage(denied),
  33 + })
  34 + return
  35 + }
  36 + resolve({ ok: true })
  37 + },
  38 + () => {
  39 + resolve({
  40 + ok: false,
  41 + message: 'Failed to request Bluetooth permissions.',
  42 + })
  43 + }
  44 + )
  45 + return
  46 + } catch (e: any) {
  47 + resolve({
  48 + ok: false,
  49 + message: e?.message || 'Failed to request Bluetooth permissions.',
  50 + })
  51 + return
  52 + }
  53 + // #endif
  54 + resolve({ ok: true })
  55 + })
  56 +}
  57 +
  58 +export async function ensureBluetoothPermissions (options?: {
  59 + scan?: boolean
  60 + connect?: boolean
  61 +}): Promise<BluetoothPermissionResult> {
  62 + const { scan = false, connect = false } = options || {}
  63 +
  64 + // #ifdef APP-PLUS
  65 + try {
  66 + if (typeof plus === 'undefined' || !plus.android) {
  67 + return { ok: true }
  68 + }
  69 + const Build = plus.android.importClass('android.os.Build')
  70 + const sdkInt = Number(Build.VERSION.SDK_INT || 0)
  71 +
  72 + const permissions = new Set<string>()
  73 + if (sdkInt >= 31) {
  74 + if (scan) permissions.add('android.permission.BLUETOOTH_SCAN')
  75 + if (scan || connect) permissions.add('android.permission.BLUETOOTH_CONNECT')
  76 + if (scan) permissions.add('android.permission.ACCESS_FINE_LOCATION')
  77 + } else if (scan) {
  78 + permissions.add('android.permission.ACCESS_FINE_LOCATION')
  79 + permissions.add('android.permission.ACCESS_COARSE_LOCATION')
  80 + }
  81 +
  82 + return await requestAndroidPermissions(Array.from(permissions))
  83 + } catch (e: any) {
  84 + return {
  85 + ok: false,
  86 + message: e?.message || 'Bluetooth permission check failed.',
  87 + }
  88 + }
  89 + // #endif
  90 +
  91 + return { ok: true }
  92 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/bluetoothTool.js
1 1 /**
2   - * 经典蓝牙工具(仅 Android APP-PLUS)
3   - * 用于佳博 D320FAX 等一体机的 Virtual BT Printer 连接
4   - * 支持 RFCOMM 回退连接(部分设备需要)
  2 + * 经典蓝牙工具(Android APP-PLUS)
  3 + * 兼容官方 UniApp SDK 经典蓝牙实现,并补充当前项目需要的简化调用:
  4 + * - getPairedDevices()
  5 + * - startClassicDiscovery(onDeviceFound, onDiscoveryFinished)
  6 + * - cancelClassicDiscovery()
  7 + * - connDevice(address, callback)
  8 + * - disConnDevice()
  9 + * - sendByteData(byteData)
5 10 */
  11 +
6 12 // #ifdef APP-PLUS
7   -function getAdapter () {
8   - if (typeof plus === 'undefined' || !plus.android) return null
9   - const BluetoothAdapter = plus.android.importClass('android.bluetooth.BluetoothAdapter')
10   - return BluetoothAdapter.getDefaultAdapter()
11   -}
  13 +let BluetoothAdapter = plus.android.importClass('android.bluetooth.BluetoothAdapter')
  14 +let Intent = plus.android.importClass('android.content.Intent')
  15 +let IntentFilter = plus.android.importClass('android.content.IntentFilter')
  16 +let BluetoothDevice = plus.android.importClass('android.bluetooth.BluetoothDevice')
  17 +let UUID = plus.android.importClass('java.util.UUID')
  18 +let Toast = plus.android.importClass('android.widget.Toast')
  19 +let MY_UUID = UUID.fromString('00001101-0000-1000-8000-00805F9B34FB')
  20 +
  21 +let invoke = plus.android.invoke
  22 +let btAdapter = BluetoothAdapter.getDefaultAdapter()
  23 +let activity = plus.android.runtimeMainActivity()
  24 +
  25 +let btSocket = null
  26 +let btInStream = null
  27 +let btOutStream = null
  28 +let setIntervalId = 0
  29 +
  30 +let btFindReceiver = null
  31 +let btStatusReceiver = null
  32 +// #endif
12 33  
13   -function getActivity () {
14   - return typeof plus !== 'undefined' ? plus.android.runtimeMainActivity() : null
  34 +function normalizeDeviceType (deviceType) {
  35 + if (deviceType === 1) return 'classic'
  36 + if (deviceType === 2) return 'ble'
  37 + if (deviceType === 3) return 'dual'
  38 + return 'unknown'
15 39 }
16 40  
17   -function showToast (msg) {
  41 +function fallbackRfcommSocket (device, insecure = false) {
18 42 try {
19   - const activity = getActivity()
20   - if (activity) {
21   - const Toast = plus.android.importClass('android.widget.Toast')
22   - Toast.makeText(activity, String(msg), Toast.LENGTH_SHORT).show()
23   - }
24   - } catch (_) {}
  43 + const cls = invoke(device, 'getClass')
  44 + const intClass = plus.android.importClass('java.lang.Integer').TYPE
  45 + const methodName = insecure ? 'createInsecureRfcommSocket' : 'createRfcommSocket'
  46 + const method = invoke(cls, 'getMethod', methodName, intClass)
  47 + return invoke(method, 'invoke', device, 1)
  48 + } catch (e) {
  49 + console.error('RFCOMM fallback failed:', e)
  50 + return null
  51 + }
25 52 }
26 53  
27   -function getInvoke () {
28   - return typeof plus !== 'undefined' && plus.android ? plus.android.invoke : null
  54 +function getErrorMessage (error) {
  55 + if (!error) return 'Unknown error'
  56 + if (typeof error === 'string') return error
  57 + return String(error.message || error.errMsg || error)
29 58 }
30   -function getMyUuid () {
31   - if (typeof plus === 'undefined' || !plus.android) return null
32   - return plus.android.importClass('java.util.UUID').fromString('00001101-0000-1000-8000-00805F9B34FB')
  59 +
  60 +function normalizeWriteByte (value) {
  61 + const byte = Number(value) || 0
  62 + return byte & 0xff
  63 +}
  64 +
  65 +function normalizeWriteChunk (byteData, start, end) {
  66 + const out = []
  67 + for (let i = start; i < end; i++) {
  68 + const value = normalizeWriteByte(byteData[i])
  69 + if (value >= 128) {
  70 + out.push(value - 256)
  71 + } else {
  72 + out.push(value)
  73 + }
  74 + }
  75 + return out
  76 +}
  77 +
  78 +function createSocketCandidates (device) {
  79 + return [
  80 + {
  81 + name: 'secure-service-record',
  82 + create: () => invoke(device, 'createRfcommSocketToServiceRecord', MY_UUID),
  83 + },
  84 + {
  85 + name: 'insecure-service-record',
  86 + create: () => invoke(device, 'createInsecureRfcommSocketToServiceRecord', MY_UUID),
  87 + },
  88 + {
  89 + name: 'secure-channel-1',
  90 + create: () => fallbackRfcommSocket(device, false),
  91 + },
  92 + {
  93 + name: 'insecure-channel-1',
  94 + create: () => fallbackRfcommSocket(device, true),
  95 + },
  96 + ]
33 97 }
34   -let btSocket = null
35   -let btInStream = null
36   -let btOutStream = null
37   -let btFindReceiver = null
38   -// #endif
39 98  
40 99 var blueToothTool = {
41   - state: { bluetoothEnable: false, readThreadState: false },
  100 + state: {
  101 + bluetoothEnable: false,
  102 + bluetoothState: '',
  103 + discoveryDeviceState: false,
  104 + readThreadState: false,
  105 + connectionState: 'idle',
  106 + lastAddress: '',
  107 + lastSocketStrategy: '',
  108 + lastError: '',
  109 + lastSendError: '',
  110 + outputReady: false,
  111 + lastSendMode: 'idle',
  112 + },
  113 + options: {
  114 + listenBTStatusCallback: function (state) {},
  115 + discoveryDeviceCallback: function (newDevice) {},
  116 + discoveryFinishedCallback: function () {},
  117 + readDataCallback: function (dataByteArr) {},
  118 + connExceptionCallback: function (e) {},
  119 + },
  120 + init (setOptions) {
  121 + Object.assign(this.options, setOptions || {})
  122 + this.state.bluetoothEnable = this.getBluetoothStatus()
  123 + this.listenBluetoothStatus()
  124 + },
  125 + setErrorState (message, type = 'general') {
  126 + const text = String(message || '')
  127 + this.state.lastError = text
  128 + if (type === 'send') {
  129 + this.state.lastSendError = text
  130 + }
  131 + },
  132 + clearErrorState () {
  133 + this.state.lastError = ''
  134 + this.state.lastSendError = ''
  135 + },
42 136 shortToast (msg) {
43 137 // #ifdef APP-PLUS
44   - showToast(msg)
  138 + try {
  139 + if (activity) Toast.makeText(activity, String(msg), Toast.LENGTH_SHORT).show()
  140 + } catch (_) {}
45 141 // #endif
46 142 },
  143 + isSupportBluetooth () {
  144 + // #ifdef APP-PLUS
  145 + return btAdapter != null
  146 + // #endif
  147 + return false
  148 + },
47 149 getBluetoothStatus () {
48 150 // #ifdef APP-PLUS
49   - const btAdapter = getAdapter()
50 151 return btAdapter != null && btAdapter.isEnabled()
51 152 // #endif
52 153 return false
53 154 },
54   - /** 获取已配对设备(含 Virtual BT Printer / D320FAX) */
  155 + turnOnBluetooth () {
  156 + // #ifdef APP-PLUS
  157 + if (btAdapter == null) {
  158 + this.shortToast('Bluetooth not available')
  159 + return
  160 + }
  161 + if (!btAdapter.isEnabled()) {
  162 + if (activity == null) {
  163 + this.shortToast('Activity not available')
  164 + return
  165 + }
  166 + let intent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
  167 + activity.startActivityForResult(intent, 1)
  168 + return
  169 + }
  170 + this.shortToast('Bluetooth already enabled')
  171 + // #endif
  172 + },
  173 + turnOffBluetooth () {
  174 + // #ifdef APP-PLUS
  175 + if (btAdapter != null && btAdapter.isEnabled()) {
  176 + btAdapter.disable()
  177 + }
  178 + if (btFindReceiver != null) {
  179 + try { activity.unregisterReceiver(btFindReceiver) } catch (_) {}
  180 + btFindReceiver = null
  181 + }
  182 + this.state.bluetoothEnable = false
  183 + this.cancelDiscovery()
  184 + this.closeBtSocket()
  185 + // #endif
  186 + },
55 187 getPairedDevices () {
56 188 // #ifdef APP-PLUS
57   - const pairedDevices = []
  189 + let pairedDevices = []
58 190 try {
59   - const btAdapter = getAdapter()
60   - if (!btAdapter || !btAdapter.isEnabled()) {
61   - this.shortToast('Bluetooth is off')
62   - return pairedDevices
63   - }
64   - const bonded = btAdapter.getBondedDevices()
65   - if (!bonded) return pairedDevices
66   - const inv = getInvoke()
67   - if (!inv) return pairedDevices
68   - const it = inv(bonded, 'iterator')
69   - while (inv(it, 'hasNext')) {
70   - const device = inv(it, 'next')
71   - const deviceType = inv(device, 'getType')
72   - const deviceId = inv(device, 'getAddress')
73   - let deviceName = inv(device, 'getName')
74   - if (deviceName == null) deviceName = ''
75   - deviceName = String(deviceName).trim() || 'Unknown Device'
76   - let typeStr = 'unknown'
77   - if (deviceType === 1) typeStr = 'classic'
78   - else if (deviceType === 2) typeStr = 'ble'
79   - else if (deviceType === 3) typeStr = 'dual'
80   - pairedDevices.push({ name: deviceName, deviceId: String(deviceId), type: typeStr })
  191 + if (btAdapter == null || !btAdapter.isEnabled()) return pairedDevices
  192 + let pairedDevicesAndroid = btAdapter.getBondedDevices()
  193 + if (!pairedDevicesAndroid) return pairedDevices
  194 + let it = invoke(pairedDevicesAndroid, 'iterator')
  195 + while (invoke(it, 'hasNext')) {
  196 + let device = invoke(it, 'next')
  197 + let deviceType = invoke(device, 'getType')
  198 + let deviceId = invoke(device, 'getAddress')
  199 + let deviceName = invoke(device, 'getName')
  200 + pairedDevices.push({
  201 + name: deviceName != null ? String(deviceName).trim() || 'Unknown Device' : 'Unknown Device',
  202 + deviceId: String(deviceId || ''),
  203 + type: normalizeDeviceType(deviceType),
  204 + })
81 205 }
82 206 } catch (e) {
83 207 console.error('getPairedDevices error:', e)
... ... @@ -86,156 +210,360 @@ var blueToothTool = {
86 210 // #endif
87 211 return []
88 212 },
89   - /** 经典蓝牙扫描(发现未配对设备,如 d320fax_295c)— 不过滤任何设备 */
90   - startClassicDiscovery (onDeviceFound, onDiscoveryFinished) {
  213 + discoveryNewDevice () {
91 214 // #ifdef APP-PLUS
92   - const btAdapter = getAdapter()
93   - const activity = getActivity()
94   - if (!btAdapter || !btAdapter.isEnabled() || !activity) return
95   - if (btFindReceiver) {
96   - try { activity.unregisterReceiver(btFindReceiver) } catch (_) {}
  215 + if (btAdapter == null || !btAdapter.isEnabled() || activity == null) return
  216 + if (btFindReceiver != null) {
  217 + try { activity.unregisterReceiver(btFindReceiver) } catch (e) { console.error(e) }
97 218 btFindReceiver = null
  219 + this.cancelDiscovery()
98 220 }
99   - if (btAdapter.isDiscovering()) btAdapter.cancelDiscovery()
100   - const BluetoothAdapter = plus.android.importClass('android.bluetooth.BluetoothAdapter')
101   - const BluetoothDevice = plus.android.importClass('android.bluetooth.BluetoothDevice')
102   - const IntentFilter = plus.android.importClass('android.content.IntentFilter')
103   - const inv = getInvoke()
  221 + let options = this.options
104 222 btFindReceiver = plus.android.implements('io.dcloud.android.content.BroadcastReceiver', {
105   - onReceive (context, intent) {
106   - const action = intent.getAction()
  223 + onReceive: function (context, intent) {
  224 + plus.android.importClass(context)
  225 + plus.android.importClass(intent)
  226 + let action = intent.getAction()
107 227 if (BluetoothDevice.ACTION_FOUND === action) {
108   - const device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
  228 + let device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
109 229 if (!device) return
110   - const deviceType = inv(device, 'getType')
111   - const deviceId = String(inv(device, 'getAddress') || '')
112   - let deviceName = inv(device, 'getName')
113   - if (deviceName == null) deviceName = ''
114   - deviceName = String(deviceName).trim() || 'Unknown Device'
115   - let typeStr = 'unknown'
116   - if (deviceType === 1) typeStr = 'classic'
117   - else if (deviceType === 2) typeStr = 'ble'
118   - else if (deviceType === 3) typeStr = 'dual'
119   - if (onDeviceFound) onDeviceFound({ name: deviceName, deviceId, type: typeStr })
  230 + let deviceType = invoke(device, 'getType')
  231 + let deviceId = invoke(device, 'getAddress')
  232 + let deviceName = invoke(device, 'getName')
  233 + let newDevice = {
  234 + name: deviceName != null ? String(deviceName).trim() || 'Unknown Device' : 'Unknown Device',
  235 + deviceId: String(deviceId || ''),
  236 + type: normalizeDeviceType(deviceType),
  237 + }
  238 + options.discoveryDeviceCallback && options.discoveryDeviceCallback(newDevice)
120 239 }
121 240 if (BluetoothAdapter.ACTION_DISCOVERY_FINISHED === action) {
122   - try { activity.unregisterReceiver(btFindReceiver) } catch (_) {}
123   - btFindReceiver = null
124   - if (onDiscoveryFinished) onDiscoveryFinished()
  241 + blueToothTool.cancelDiscovery()
  242 + options.discoveryFinishedCallback && options.discoveryFinishedCallback()
125 243 }
126 244 },
127 245 })
128   - const filter = new IntentFilter()
  246 + let filter = new IntentFilter()
129 247 filter.addAction(BluetoothDevice.ACTION_FOUND)
130 248 filter.addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED)
131 249 activity.registerReceiver(btFindReceiver, filter)
132 250 btAdapter.startDiscovery()
  251 + this.state.discoveryDeviceState = true
133 252 // #endif
134 253 },
135   - /** 停止经典蓝牙扫描 */
136   - cancelClassicDiscovery () {
  254 + startClassicDiscovery (onDeviceFound, onDiscoveryFinished) {
  255 + this.init({
  256 + discoveryDeviceCallback: onDeviceFound || function () {},
  257 + discoveryFinishedCallback: onDiscoveryFinished || function () {},
  258 + })
  259 + this.discoveryNewDevice()
  260 + },
  261 + listenBluetoothStatus () {
137 262 // #ifdef APP-PLUS
138   - const btAdapter = getAdapter()
139   - const activity = getActivity()
140   - if (btAdapter && btAdapter.isDiscovering()) btAdapter.cancelDiscovery()
141   - if (btFindReceiver && activity) {
142   - try { activity.unregisterReceiver(btFindReceiver) } catch (_) {}
143   - btFindReceiver = null
  263 + if (activity == null) return
  264 + if (btStatusReceiver != null) {
  265 + try { activity.unregisterReceiver(btStatusReceiver) } catch (e) { console.error(e) }
  266 + btStatusReceiver = null
  267 + }
  268 + btStatusReceiver = plus.android.implements('io.dcloud.android.content.BroadcastReceiver', {
  269 + onReceive: (context, intent) => {
  270 + plus.android.importClass(context)
  271 + plus.android.importClass(intent)
  272 + let action = intent.getAction()
  273 + if (action === BluetoothAdapter.ACTION_STATE_CHANGED) {
  274 + let blueState = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, 0)
  275 + let stateStr = ''
  276 + switch (blueState) {
  277 + case BluetoothAdapter.STATE_TURNING_ON:
  278 + stateStr = 'STATE_TURNING_ON'
  279 + break
  280 + case BluetoothAdapter.STATE_ON:
  281 + stateStr = 'STATE_ON'
  282 + this.state.bluetoothEnable = true
  283 + break
  284 + case BluetoothAdapter.STATE_TURNING_OFF:
  285 + stateStr = 'STATE_TURNING_OFF'
  286 + break
  287 + case BluetoothAdapter.STATE_OFF:
  288 + stateStr = 'STATE_OFF'
  289 + this.state.bluetoothEnable = false
  290 + break
  291 + }
  292 + this.state.bluetoothState = stateStr
  293 + this.options.listenBTStatusCallback && this.options.listenBTStatusCallback(stateStr)
  294 + }
  295 + },
  296 + })
  297 + let filter = new IntentFilter()
  298 + filter.addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
  299 + activity.registerReceiver(btStatusReceiver, filter)
  300 + if (this.state.bluetoothEnable) {
  301 + this.options.listenBTStatusCallback && this.options.listenBTStatusCallback('STATE_ON')
144 302 }
145 303 // #endif
146 304 },
147   - /** 连接经典蓝牙设备(含 D320FAX Virtual BT Printer,支持 RFCOMM 回退) */
148 305 connDevice (address, callback) {
149 306 // #ifdef APP-PLUS
  307 + this.cancelDiscovery()
150 308 if (btSocket != null) this.closeBtSocket()
151 309 this.state.readThreadState = false
152   - const btAdapter = getAdapter()
153   - if (!btAdapter) {
  310 + this.state.connectionState = 'connecting'
  311 + this.state.lastAddress = String(address || '')
  312 + this.state.outputReady = false
  313 + this.clearErrorState()
  314 +
  315 + if (btAdapter == null) {
154 316 this.shortToast('Bluetooth not available')
155   - if (callback) callback(false)
156   - return false
157   - }
158   - const inv = getInvoke()
159   - const uuid = getMyUuid()
160   - if (!inv || !uuid) {
161   - this.shortToast('Bluetooth not ready')
162   - if (callback) callback(false)
  317 + this.state.connectionState = 'error'
  318 + this.setErrorState('Bluetooth not available')
  319 + callback && callback(false)
163 320 return false
164 321 }
165 322 try {
166   - const device = inv(btAdapter, 'getRemoteDevice', address)
167   - btSocket = inv(device, 'createRfcommSocketToServiceRecord', uuid)
168   - } catch (e) {
169   - console.warn('createRfcommSocketToServiceRecord failed, try fallback:', e)
170   - try {
171   - const device = inv(btAdapter, 'getRemoteDevice', address)
172   - const cls = inv(device, 'getClass')
173   - const intClass = plus.android.importClass('java.lang.Integer').TYPE
174   - const m = inv(cls, 'getMethod', 'createRfcommSocket', intClass)
175   - btSocket = inv(m, 'invoke', device, 1)
176   - } catch (e2) {
177   - console.error('RFCOMM fallback failed:', e2)
178   - this.shortToast('Connect failed')
179   - if (callback) callback(false)
  323 + let device = invoke(btAdapter, 'getRemoteDevice', address)
  324 + const candidates = createSocketCandidates(device)
  325 + let socket = null
  326 + let lastError = null
  327 + for (let i = 0; i < candidates.length; i++) {
  328 + const candidate = candidates[i]
  329 + try {
  330 + socket = candidate.create()
  331 + if (socket) {
  332 + this.state.lastSocketStrategy = candidate.name
  333 + break
  334 + }
  335 + } catch (e) {
  336 + lastError = e
  337 + console.warn('create socket failed:', candidate.name, e)
  338 + }
  339 + }
  340 + btSocket = socket
  341 + if (!btSocket) {
  342 + this.state.connectionState = 'error'
  343 + this.setErrorState(lastError ? getErrorMessage(lastError) : 'Unable to create Bluetooth socket')
  344 + callback && callback(false)
180 345 return false
181 346 }
  347 + } catch (e) {
  348 + console.error(e)
  349 + this.state.connectionState = 'error'
  350 + this.setErrorState(getErrorMessage(e))
  351 + callback && callback(false)
  352 + return false
182 353 }
  354 +
183 355 try {
184   - inv(btSocket, 'connect')
185   - btInStream = inv(btSocket, 'getInputStream')
186   - btOutStream = inv(btSocket, 'getOutputStream')
187   - this.state.readThreadState = true
188   - this.shortToast('Connected')
189   - if (callback) callback(true)
  356 + invoke(btSocket, 'connect')
  357 + const streamReady = this.readData()
  358 + if (!streamReady) {
  359 + throw new Error(this.state.lastError || 'Bluetooth output stream not ready')
  360 + }
  361 + this.state.connectionState = 'connected'
  362 + this.shortToast('Classic Bluetooth connected')
  363 + callback && callback(true)
190 364 } catch (e) {
191   - try { btSocket.close() } catch (_) {}
192   - btSocket = null
193   - this.shortToast('Connect failed')
194   - if (callback) callback(false)
  365 + console.error(e)
  366 + this.state.connectionState = 'error'
  367 + this.setErrorState(getErrorMessage(e))
  368 + callback && callback(false)
  369 + try {
  370 + btSocket.close()
  371 + btSocket = null
  372 + } catch (e1) {
  373 + console.error(e1)
  374 + }
  375 + btInStream = null
  376 + btOutStream = null
195 377 return false
196 378 }
197 379 return true
198 380 // #endif
199   - if (callback) callback(false)
  381 + callback && callback(false)
200 382 return false
201 383 },
202 384 disConnDevice () {
203 385 // #ifdef APP-PLUS
204   - this.closeBtSocket()
  386 + if (btSocket != null) this.closeBtSocket()
205 387 this.state.readThreadState = false
206   - this.shortToast('Disconnected')
207 388 // #endif
208 389 },
209 390 closeBtSocket () {
210 391 // #ifdef APP-PLUS
211 392 this.state.readThreadState = false
212   - if (btSocket) {
213   - try { btSocket.close() } catch (_) {}
214   - btSocket = null
215   - btInStream = null
216   - btOutStream = null
  393 + this.state.outputReady = false
  394 + this.state.connectionState = 'idle'
  395 + clearInterval(setIntervalId)
  396 + setIntervalId = 0
  397 + if (!btSocket) return
  398 + try {
  399 + btSocket.close()
  400 + } catch (e) {
  401 + console.error(e)
217 402 }
  403 + btSocket = null
  404 + btInStream = null
  405 + btOutStream = null
218 406 // #endif
219 407 },
220   - sendByteData (byteData) {
  408 + cancelDiscovery () {
  409 + // #ifdef APP-PLUS
  410 + if (btAdapter != null && btAdapter.isDiscovering()) {
  411 + btAdapter.cancelDiscovery()
  412 + }
  413 + if (btFindReceiver != null && activity != null) {
  414 + try { activity.unregisterReceiver(btFindReceiver) } catch (_) {}
  415 + btFindReceiver = null
  416 + }
  417 + this.state.discoveryDeviceState = false
  418 + // #endif
  419 + },
  420 + cancelClassicDiscovery () {
  421 + this.cancelDiscovery()
  422 + },
  423 + readData () {
  424 + // #ifdef APP-PLUS
  425 + if (!btSocket) {
  426 + this.shortToast('Please connect Bluetooth device first.')
  427 + this.state.connectionState = 'error'
  428 + this.setErrorState('Please connect Bluetooth device first.')
  429 + return false
  430 + }
  431 + try {
  432 + btInStream = invoke(btSocket, 'getInputStream')
  433 + btOutStream = invoke(btSocket, 'getOutputStream')
  434 + } catch (e) {
  435 + console.error(e)
  436 + this.setErrorState(getErrorMessage(e))
  437 + this.closeBtSocket()
  438 + return false
  439 + }
  440 + this.read()
  441 + this.state.readThreadState = true
  442 + this.state.outputReady = !!btOutStream
  443 + return true
  444 + // #endif
  445 + return false
  446 + },
  447 + read () {
  448 + // #ifdef APP-PLUS
  449 + clearInterval(setIntervalId)
  450 + setIntervalId = setInterval(() => {
  451 + if (this.state.readThreadState) {
  452 + let start = new Date().getTime()
  453 + let dataArr = []
  454 + try {
  455 + while (btInStream && invoke(btInStream, 'available') !== 0) {
  456 + let data = invoke(btInStream, 'read')
  457 + dataArr.push(data)
  458 + let current = new Date().getTime()
  459 + if (current - start > 20) break
  460 + }
  461 + } catch (e) {
  462 + this.state.readThreadState = false
  463 + this.state.connectionState = 'error'
  464 + this.setErrorState(getErrorMessage(e))
  465 + this.options.connExceptionCallback && this.options.connExceptionCallback(e)
  466 + }
  467 + if (dataArr.length > 0) {
  468 + this.options.readDataCallback && this.options.readDataCallback(dataArr)
  469 + }
  470 + }
  471 + }, 40)
  472 + // #endif
  473 + },
  474 + sendData (dataStr) {
221 475 // #ifdef APP-PLUS
222 476 if (!btOutStream) {
  477 + this.shortToast('Output stream not ready')
  478 + return false
  479 + }
  480 + let bytes = invoke(dataStr, 'getBytes', 'gb18030')
  481 + try {
  482 + this.sendByteData(bytes)
  483 + } catch (e) {
  484 + return false
  485 + }
  486 + return true
  487 + // #endif
  488 + return false
  489 + },
  490 + sendByteData (byteData) {
  491 + // #ifdef APP-PLUS
  492 + if (!this.ensureConnection(this.state.lastAddress)) {
223 493 this.shortToast('Not connected')
224 494 return false
225 495 }
226 496 try {
227   - const CHUNK_SIZE = 4096
  497 + const CHUNK_SIZE = 512
  498 + this.state.lastSendMode = 'chunk-write'
  499 + this.state.lastSendError = ''
228 500 for (let i = 0; i < byteData.length; i += CHUNK_SIZE) {
229   - const chunk = byteData.slice(i, i + CHUNK_SIZE)
230   - btOutStream.write(chunk)
  501 + const chunk = normalizeWriteChunk(byteData, i, Math.min(i + CHUNK_SIZE, byteData.length))
  502 + try {
  503 + btOutStream.write(chunk)
  504 + } catch (writeChunkError) {
  505 + this.state.lastSendMode = 'byte-write-fallback'
  506 + for (let j = 0; j < chunk.length; j++) {
  507 + invoke(btOutStream, 'write', normalizeWriteByte(chunk[j]))
  508 + }
  509 + }
231 510 }
  511 + invoke(btOutStream, 'flush')
232 512 return true
233 513 } catch (e) {
  514 + const message = getErrorMessage(e)
  515 + console.error('sendByteData failed:', e)
  516 + this.setErrorState(message, 'send')
  517 + this.state.connectionState = 'error'
234 518 return false
235 519 }
236 520 // #endif
237 521 return false
238   - }
  522 + },
  523 + isSocketConnected () {
  524 + // #ifdef APP-PLUS
  525 + if (!btSocket) return false
  526 + try {
  527 + return !!invoke(btSocket, 'isConnected')
  528 + } catch (_) {
  529 + return false
  530 + }
  531 + // #endif
  532 + return false
  533 + },
  534 + ensureConnection (address) {
  535 + // #ifdef APP-PLUS
  536 + const targetAddress = String(address || this.state.lastAddress || '')
  537 + if (targetAddress) {
  538 + this.state.lastAddress = targetAddress
  539 + }
  540 + if (btOutStream && this.isSocketConnected()) {
  541 + this.state.outputReady = true
  542 + return true
  543 + }
  544 + if (!targetAddress) {
  545 + this.setErrorState('Bluetooth address missing')
  546 + return false
  547 + }
  548 + let connected = false
  549 + this.connDevice(targetAddress, (ok) => {
  550 + connected = !!ok
  551 + })
  552 + return connected && !!btOutStream
  553 + // #endif
  554 + return false
  555 + },
  556 + getLastError () {
  557 + return this.state.lastSendError || this.state.lastError || ''
  558 + },
  559 + getDebugState () {
  560 + return {
  561 + ...this.state,
  562 + socketConnected: this.isSocketConnected(),
  563 + outputReady: !!btOutStream,
  564 + inputReady: !!btInStream,
  565 + }
  566 + },
239 567 }
240 568  
241 569 export default blueToothTool
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/drivers/d320fax.ts 0 → 100644
  1 +import { buildTscLabelData, buildTscTestPrintData } from '../protocols/tscProtocol'
  2 +import type { PrinterCandidate, PrinterDriver } from '../types/printer'
  3 +
  4 +const KEYWORDS = [
  5 + 'd320fax',
  6 + 'd320fx',
  7 + 'virtual bt printer',
  8 + 'gp-d320fax',
  9 +]
  10 +
  11 +function score (device: PrinterCandidate): number {
  12 + const text = `${device.name || ''} ${device.deviceId || ''}`.toLowerCase()
  13 + let total = 0
  14 + KEYWORDS.forEach((keyword) => {
  15 + if (text.includes(keyword)) total += 40
  16 + })
  17 + return total
  18 +}
  19 +
  20 +export const d320faxDriver: PrinterDriver = {
  21 + key: 'd320fax',
  22 + brand: 'Gprinter',
  23 + model: 'D320FAX/D320FX',
  24 + displayName: 'Gprinter D320FAX',
  25 + protocol: 'tsc',
  26 + preferredConnection: 'classic',
  27 + preferredBleMtu: 20,
  28 + keywords: KEYWORDS,
  29 + matches (device) {
  30 + return score(device)
  31 + },
  32 + resolveConnectionType () {
  33 + return 'classic'
  34 + },
  35 + buildTestPrintData () {
  36 + return buildTscTestPrintData()
  37 + },
  38 + buildLabelData (payload) {
  39 + return buildTscLabelData(payload)
  40 + },
  41 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/drivers/genericTsc.ts 0 → 100644
  1 +import { buildTscLabelData, buildTscTestPrintData } from '../protocols/tscProtocol'
  2 +import type { PrinterCandidate, PrinterDriver } from '../types/printer'
  3 +
  4 +const GENERIC_TSC_KEYWORDS = [
  5 + 'printer', 'print', 'label', 'tsc', 'zebra', 'brother', 'epson', 'godex',
  6 + 'citizen', 'ql-', 'zd', 'zt', 'ttp', 'tdp', 'bt printer', 'virtual bt printer',
  7 +]
  8 +
  9 +function scoreByKeywords (device: PrinterCandidate, keywords: string[]): number {
  10 + const text = `${device.name || ''} ${device.deviceId || ''}`.toLowerCase()
  11 + let score = 0
  12 + keywords.forEach((keyword) => {
  13 + if (text.includes(keyword)) score += 10
  14 + })
  15 + return score
  16 +}
  17 +
  18 +export const genericTscDriver: PrinterDriver = {
  19 + key: 'generic-tsc',
  20 + brand: 'Generic',
  21 + model: 'TSC',
  22 + displayName: 'Generic TSC Printer',
  23 + protocol: 'tsc',
  24 + preferredBleMtu: 20,
  25 + keywords: GENERIC_TSC_KEYWORDS,
  26 + matches (device) {
  27 + return scoreByKeywords(device, GENERIC_TSC_KEYWORDS)
  28 + },
  29 + resolveConnectionType (device) {
  30 + return device.type === 'ble' ? 'ble' : 'classic'
  31 + },
  32 + buildTestPrintData () {
  33 + return buildTscTestPrintData()
  34 + },
  35 + buildLabelData (payload) {
  36 + return buildTscLabelData(payload)
  37 + },
  38 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/drivers/gpR3.ts 0 → 100644
  1 +import { buildEscPosLabelData, buildEscPosTestPrintData } from '../protocols/escPosBuilder'
  2 +import type { PrinterCandidate, PrinterDriver } from '../types/printer'
  3 +
  4 +const KEYWORDS = [
  5 + 'gp-r3',
  6 + 'gp r3',
  7 + 'gpr3',
  8 +]
  9 +
  10 +function score (device: PrinterCandidate): number {
  11 + const text = `${device.name || ''} ${device.deviceId || ''}`.toLowerCase()
  12 + let total = 0
  13 + KEYWORDS.forEach((keyword) => {
  14 + if (text.includes(keyword)) total += 60
  15 + })
  16 + if (text.includes('gprinter')) total += 10
  17 + return total
  18 +}
  19 +
  20 +export const gpR3Driver: PrinterDriver = {
  21 + key: 'gp-r3',
  22 + brand: 'Gprinter',
  23 + model: 'GP-R3',
  24 + displayName: 'Gprinter GP-R3',
  25 + protocol: 'esc',
  26 + preferredConnection: 'classic',
  27 + preferredBleMtu: 20,
  28 + keywords: KEYWORDS,
  29 + matches (device) {
  30 + return score(device)
  31 + },
  32 + resolveConnectionType (device) {
  33 + if (device.type === 'ble') return 'ble'
  34 + return 'classic'
  35 + },
  36 + buildTestPrintData () {
  37 + return buildEscPosTestPrintData()
  38 + },
  39 + buildLabelData (payload) {
  40 + return buildEscPosLabelData(payload)
  41 + },
  42 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/manager/driverRegistry.ts 0 → 100644
  1 +import { d320faxDriver } from '../drivers/d320fax'
  2 +import { genericTscDriver } from '../drivers/genericTsc'
  3 +import { gpR3Driver } from '../drivers/gpR3'
  4 +import type { PrinterCandidate, PrinterDriver, ResolvedPrinterCandidate } from '../types/printer'
  5 +
  6 +const printerDrivers: PrinterDriver[] = [
  7 + gpR3Driver,
  8 + d320faxDriver,
  9 + genericTscDriver,
  10 +]
  11 +
  12 +export function getPrinterDrivers (): PrinterDriver[] {
  13 + return printerDrivers
  14 +}
  15 +
  16 +export function getPrinterDriverByKey (key?: string): PrinterDriver {
  17 + return printerDrivers.find(driver => driver.key === key) || genericTscDriver
  18 +}
  19 +
  20 +export function resolvePrinterDriver (device: PrinterCandidate): PrinterDriver {
  21 + let bestDriver: PrinterDriver = genericTscDriver
  22 + let bestScore = -1
  23 + printerDrivers.forEach((driver) => {
  24 + const score = driver.matches(device)
  25 + if (score > bestScore) {
  26 + bestScore = score
  27 + bestDriver = driver
  28 + }
  29 + })
  30 + return bestDriver
  31 +}
  32 +
  33 +export function describePrinterCandidate (device: PrinterCandidate): ResolvedPrinterCandidate {
  34 + const driver = resolvePrinterDriver(device)
  35 + return {
  36 + ...device,
  37 + driverKey: driver.key,
  38 + driverName: driver.displayName,
  39 + protocol: driver.protocol,
  40 + resolvedType: driver.resolveConnectionType(device),
  41 + }
  42 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts 0 → 100644
  1 +import {
  2 + clearPrinter,
  3 + getBluetoothConnection,
  4 + getCurrentPrinterDriverKey,
  5 + getPrinterType,
  6 + sendToPrinter,
  7 + setBluetoothConnection,
  8 + setBuiltinPrinter,
  9 +} from '../printerConnection'
  10 +import classicBluetooth from '../bluetoothTool.js'
  11 +import { describePrinterCandidate, getPrinterDriverByKey, resolvePrinterDriver } from './driverRegistry'
  12 +import type {
  13 + CurrentPrinterSummary,
  14 + LabelPrintPayload,
  15 + PrinterCandidate,
  16 + PrinterDriver,
  17 +} from '../types/printer'
  18 +
  19 +function connectClassicBluetooth (device: PrinterCandidate, driver: PrinterDriver): Promise<void> {
  20 + return new Promise((resolve, reject) => {
  21 + // #ifdef APP-PLUS
  22 + const classic = classicBluetooth
  23 + if (!classic || !classic.connDevice) {
  24 + reject(new Error('Classic Bluetooth not available. Ensure app is running on the device.'))
  25 + return
  26 + }
  27 + classic.connDevice(device.deviceId, (ok: boolean) => {
  28 + if (!ok) {
  29 + reject(new Error('Classic Bluetooth connection failed.'))
  30 + return
  31 + }
  32 + setBluetoothConnection({
  33 + deviceId: device.deviceId,
  34 + deviceName: device.name || 'Bluetooth Printer',
  35 + deviceType: 'classic',
  36 + driverKey: driver.key,
  37 + mtu: driver.preferredBleMtu || 20,
  38 + })
  39 + resolve()
  40 + })
  41 + // #endif
  42 + // #ifndef APP-PLUS
  43 + reject(new Error('Classic Bluetooth requires the app.'))
  44 + // #endif
  45 + })
  46 +}
  47 +
  48 +function findBleWriteCharacteristic (deviceId: string): Promise<{ serviceId: string; characteristicId: string } | null> {
  49 + return new Promise((resolve) => {
  50 + uni.getBLEDeviceServices({
  51 + deviceId,
  52 + success: (serviceRes) => {
  53 + const services = serviceRes.services || []
  54 + const next = (index: number) => {
  55 + if (index >= services.length) {
  56 + resolve(null)
  57 + return
  58 + }
  59 + const serviceId = services[index].uuid
  60 + uni.getBLEDeviceCharacteristics({
  61 + deviceId,
  62 + serviceId,
  63 + success: (charRes) => {
  64 + const target = (charRes.characteristics || []).find((item: any) => item.properties && item.properties.write)
  65 + if (target) {
  66 + resolve({
  67 + serviceId,
  68 + characteristicId: target.uuid,
  69 + })
  70 + return
  71 + }
  72 + next(index + 1)
  73 + },
  74 + fail: () => next(index + 1),
  75 + })
  76 + }
  77 + next(0)
  78 + },
  79 + fail: () => resolve(null),
  80 + })
  81 + })
  82 +}
  83 +
  84 +function connectBlePrinter (device: PrinterCandidate, driver: PrinterDriver): Promise<void> {
  85 + const finalizeExistingBleConnection = async () => {
  86 + const write = await findBleWriteCharacteristic(device.deviceId)
  87 + if (!write) {
  88 + throw new Error('No writable characteristic found. This device may not support printing.')
  89 + }
  90 + setBluetoothConnection({
  91 + deviceId: device.deviceId,
  92 + deviceName: device.name || 'Bluetooth Printer',
  93 + serviceId: write.serviceId,
  94 + characteristicId: write.characteristicId,
  95 + deviceType: 'ble',
  96 + mtu: driver.preferredBleMtu || 20,
  97 + driverKey: driver.key,
  98 + })
  99 + }
  100 +
  101 + return new Promise((resolve, reject) => {
  102 + uni.createBLEConnection({
  103 + deviceId: device.deviceId,
  104 + timeout: 10000,
  105 + success: async () => {
  106 + try {
  107 + await finalizeExistingBleConnection()
  108 + resolve()
  109 + } catch (e: any) {
  110 + reject(e)
  111 + }
  112 + },
  113 + fail: (err: any) => {
  114 + if (err?.errCode === -1) {
  115 + finalizeExistingBleConnection().then(() => resolve()).catch(reject)
  116 + } else {
  117 + reject(new Error(err?.errMsg || 'BLE connection failed.'))
  118 + }
  119 + },
  120 + })
  121 + })
  122 +}
  123 +
  124 +export async function connectBluetoothPrinter (device: PrinterCandidate): Promise<PrinterDriver> {
  125 + const driver = resolvePrinterDriver(device)
  126 + const resolvedType = driver.resolveConnectionType(device)
  127 + if (resolvedType === 'classic') {
  128 + await connectClassicBluetooth(device, driver)
  129 + } else {
  130 + await connectBlePrinter(device, driver)
  131 + }
  132 + return driver
  133 +}
  134 +
  135 +export function useBuiltinPrinter (driverKey = 'generic-tsc') {
  136 + setBuiltinPrinter(driverKey)
  137 +}
  138 +
  139 +export function getCurrentPrinterDriver (): PrinterDriver {
  140 + const type = getPrinterType()
  141 + const storedKey = getCurrentPrinterDriverKey()
  142 + if (storedKey) return getPrinterDriverByKey(storedKey)
  143 + if (type === 'bluetooth') {
  144 + const connection = getBluetoothConnection()
  145 + if (connection) {
  146 + return resolvePrinterDriver({
  147 + deviceId: connection.deviceId,
  148 + name: connection.deviceName,
  149 + type: connection.deviceType,
  150 + })
  151 + }
  152 + }
  153 + return getPrinterDriverByKey('generic-tsc')
  154 +}
  155 +
  156 +export function getCurrentPrinterSummary (): CurrentPrinterSummary {
  157 + const type = getPrinterType()
  158 + const driver = getCurrentPrinterDriver()
  159 + if (type === 'builtin') {
  160 + return {
  161 + type,
  162 + displayName: 'Built-in Printer',
  163 + deviceId: 'builtin',
  164 + driverKey: driver.key,
  165 + driverName: driver.displayName,
  166 + protocol: driver.protocol,
  167 + deviceType: '',
  168 + }
  169 + }
  170 + if (type === 'bluetooth') {
  171 + const connection = getBluetoothConnection()
  172 + if (connection) {
  173 + return {
  174 + type,
  175 + displayName: connection.deviceName || driver.displayName,
  176 + deviceId: connection.deviceId,
  177 + driverKey: driver.key,
  178 + driverName: driver.displayName,
  179 + protocol: driver.protocol,
  180 + deviceType: connection.deviceType,
  181 + }
  182 + }
  183 + }
  184 + return {
  185 + type: '',
  186 + displayName: '',
  187 + deviceId: '',
  188 + driverKey: driver.key,
  189 + driverName: driver.displayName,
  190 + protocol: driver.protocol,
  191 + deviceType: '',
  192 + }
  193 +}
  194 +
  195 +export async function testPrintCurrentPrinter (onProgress?: (percent: number) => void): Promise<PrinterDriver> {
  196 + const driver = getCurrentPrinterDriver()
  197 + await sendToPrinter(driver.buildTestPrintData(), onProgress)
  198 + return driver
  199 +}
  200 +
  201 +export async function printLabelForCurrentPrinter (
  202 + payload: LabelPrintPayload,
  203 + onProgress?: (percent: number) => void
  204 +): Promise<PrinterDriver> {
  205 + const driver = getCurrentPrinterDriver()
  206 + await sendToPrinter(driver.buildLabelData(payload), onProgress)
  207 + return driver
  208 +}
  209 +
  210 +export function describeDiscoveredPrinter (device: PrinterCandidate) {
  211 + return describePrinterCandidate(device)
  212 +}
  213 +
  214 +export function disconnectCurrentPrinter (): Promise<void> {
  215 + return new Promise((resolve) => {
  216 + const type = getPrinterType()
  217 + const connection = getBluetoothConnection()
  218 +
  219 + if (type === 'bluetooth' && connection?.deviceType === 'classic') {
  220 + // #ifdef APP-PLUS
  221 + try {
  222 + const classic = classicBluetooth
  223 + if (classic && classic.disConnDevice) classic.disConnDevice()
  224 + } catch (e) {
  225 + console.error('Disconnect classic bluetooth failed', e)
  226 + }
  227 + // #endif
  228 + clearPrinter()
  229 + resolve()
  230 + return
  231 + }
  232 +
  233 + clearPrinter()
  234 + if (type === 'bluetooth' && connection?.deviceId) {
  235 + uni.closeBLEConnection({
  236 + deviceId: connection.deviceId,
  237 + complete: () => resolve(),
  238 + })
  239 + return
  240 + }
  241 + resolve()
  242 + })
  243 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/printerConnection.ts
1 1 /**
2 2 * 打印机连接与下发:蓝牙(BLE) / 一体机(TCP localhost)
3 3 */
  4 +import type { ActiveBtDeviceType, PrinterType } from './types/printer'
  5 +import classicBluetooth from './bluetoothTool.js'
4 6  
5 7 const STORAGE_PRINTER_TYPE = 'printerType'
6 8 const STORAGE_BT_DEVICE_ID = 'btDeviceId'
... ... @@ -9,9 +11,11 @@ const STORAGE_BT_SERVICE_ID = &#39;btServiceId&#39;
9 11 const STORAGE_BT_CHARACTERISTIC_ID = 'btCharacteristicId'
10 12 const STORAGE_BT_DEVICE_TYPE = 'btDeviceType' // 'ble' | 'classic'
11 13 const STORAGE_BLE_MTU = 'bleMTU'
  14 +const STORAGE_BUILTIN_PORT = 'builtinPort'
  15 +const STORAGE_PRINTER_DRIVER_KEY = 'printerDriverKey'
12 16  
13   -export type PrinterType = 'bluetooth' | 'builtin'
14   -export type BtDeviceType = 'ble' | 'classic'
  17 +const BUILTIN_PROBE_PORTS = [9100, 4000, 9000, 6000]
  18 +export type BtDeviceType = ActiveBtDeviceType
15 19  
16 20 export const PrinterStorageKeys = {
17 21 type: STORAGE_PRINTER_TYPE,
... ... @@ -21,6 +25,7 @@ export const PrinterStorageKeys = {
21 25 btCharacteristicId: STORAGE_BT_CHARACTERISTIC_ID,
22 26 btDeviceType: STORAGE_BT_DEVICE_TYPE,
23 27 bleMTU: STORAGE_BLE_MTU,
  28 + driverKey: STORAGE_PRINTER_DRIVER_KEY,
24 29 } as const
25 30  
26 31 export function setPrinterType (type: PrinterType) {
... ... @@ -34,6 +39,7 @@ export function setBluetoothConnection (info: {
34 39 characteristicId?: string
35 40 deviceType?: BtDeviceType
36 41 mtu?: number
  42 + driverKey?: string
37 43 }) {
38 44 uni.setStorageSync(STORAGE_PRINTER_TYPE, 'bluetooth')
39 45 uni.setStorageSync(STORAGE_BT_DEVICE_ID, info.deviceId)
... ... @@ -42,10 +48,12 @@ export function setBluetoothConnection (info: {
42 48 uni.setStorageSync(STORAGE_BT_CHARACTERISTIC_ID, info.characteristicId || '')
43 49 uni.setStorageSync(STORAGE_BT_DEVICE_TYPE, info.deviceType || 'ble')
44 50 uni.setStorageSync(STORAGE_BLE_MTU, info.mtu != null ? info.mtu : BLE_MTU_DEFAULT)
  51 + uni.setStorageSync(STORAGE_PRINTER_DRIVER_KEY, info.driverKey || '')
45 52 }
46 53  
47   -export function setBuiltinPrinter () {
  54 +export function setBuiltinPrinter (driverKey = 'generic-tsc') {
48 55 uni.setStorageSync(STORAGE_PRINTER_TYPE, 'builtin')
  56 + uni.setStorageSync(STORAGE_PRINTER_DRIVER_KEY, driverKey)
49 57 }
50 58  
51 59 export function clearPrinter () {
... ... @@ -56,6 +64,8 @@ export function clearPrinter () {
56 64 uni.removeStorageSync(STORAGE_BT_CHARACTERISTIC_ID)
57 65 uni.removeStorageSync(STORAGE_BT_DEVICE_TYPE)
58 66 uni.removeStorageSync(STORAGE_BLE_MTU)
  67 + uni.removeStorageSync(STORAGE_BUILTIN_PORT)
  68 + uni.removeStorageSync(STORAGE_PRINTER_DRIVER_KEY)
59 69 }
60 70  
61 71 const BLE_MTU_DEFAULT = 20
... ... @@ -64,6 +74,10 @@ export function getPrinterType (): PrinterType | &#39;&#39; {
64 74 return (uni.getStorageSync(STORAGE_PRINTER_TYPE) as PrinterType) || ''
65 75 }
66 76  
  77 +export function getCurrentPrinterDriverKey (): string {
  78 + return String(uni.getStorageSync(STORAGE_PRINTER_DRIVER_KEY) || '')
  79 +}
  80 +
67 81 export function getBluetoothConnection (): {
68 82 deviceId: string
69 83 deviceName: string
... ... @@ -182,20 +196,31 @@ function sendViaClassic (
182 196 }
183 197 return new Promise((resolve, reject) => {
184 198 try {
185   - const classicBluetooth = (require('./bluetoothTool.js') as any).default
186 199 if (!classicBluetooth) {
187 200 reject(new Error('Classic Bluetooth not available'))
188 201 return
189 202 }
190   - const sendData = data.map((byte) => {
191   - const b = byte & 0xff
192   - if (b >= 128) return b % 128 - 128
193   - return b
194   - })
  203 + const ready = typeof classicBluetooth.ensureConnection === 'function'
  204 + ? classicBluetooth.ensureConnection(conn.deviceId)
  205 + : true
  206 + if (!ready) {
  207 + const errorMessage = typeof classicBluetooth.getLastError === 'function'
  208 + ? classicBluetooth.getLastError()
  209 + : ''
  210 + reject(new Error(errorMessage || 'Classic Bluetooth connection is not ready'))
  211 + return
  212 + }
  213 +
  214 + const sendData = data.map((byte) => byte & 0xff)
195 215 const ok = classicBluetooth.sendByteData(sendData)
196 216 if (onProgress) onProgress(100)
197 217 if (ok) resolve()
198   - else reject(new Error('Classic Bluetooth send failed'))
  218 + else {
  219 + const errorMessage = typeof classicBluetooth.getLastError === 'function'
  220 + ? classicBluetooth.getLastError()
  221 + : ''
  222 + reject(new Error(errorMessage || 'Classic Bluetooth send failed'))
  223 + }
199 224 } catch (e: any) {
200 225 reject(e)
201 226 }
... ... @@ -219,24 +244,46 @@ function sendViaBuiltin (data: number[]): Promise&lt;void&gt; {
219 244 const hexStr = Array.from(uint8)
220 245 .map(b => ('0' + (b & 0xff).toString(16)).slice(-2))
221 246 .join('')
222   - return new Promise((resolve, reject) => {
223   - moeTcp.connect({ ip: '127.0.0.1', port: 9100 }, (res: string) => {
224   - try {
225   - const r = typeof res === 'string' ? JSON.parse(res) : res
226   - if (r.code !== 1) {
227   - reject(new Error(r.msg || 'Built-in printer connection failed'))
228   - return
229   - }
230   - moeTcp.sendHexStr({ message: hexStr })
231   - setTimeout(() => {
  247 +
  248 + const savedPort = parseInt(uni.getStorageSync(STORAGE_BUILTIN_PORT)) || 0
  249 + const ports = savedPort > 0
  250 + ? [savedPort, ...BUILTIN_PROBE_PORTS.filter(p => p !== savedPort)]
  251 + : [...BUILTIN_PROBE_PORTS]
  252 +
  253 + function tryPort (idx: number): Promise<void> {
  254 + if (idx >= ports.length) {
  255 + return Promise.reject(new Error(
  256 + 'Built-in printer: all ports failed (tried ' + BUILTIN_PROBE_PORTS.join(', ') +
  257 + '). Check if the printer service is running on this device.'
  258 + ))
  259 + }
  260 + const port = ports[idx]
  261 + return new Promise((resolve, reject) => {
  262 + console.log('[builtin] trying 127.0.0.1:' + port)
  263 + moeTcp.connect({ ip: '127.0.0.1', port }, (res: string) => {
  264 + try {
  265 + const r = typeof res === 'string' ? JSON.parse(res) : res
  266 + if (r.code !== 1) {
  267 + console.log('[builtin] port ' + port + ' failed: ' + (r.msg || ''))
  268 + try { moeTcp.disconnect() } catch (_) {}
  269 + tryPort(idx + 1).then(resolve).catch(reject)
  270 + return
  271 + }
  272 + console.log('[builtin] connected on port ' + port)
  273 + uni.setStorageSync(STORAGE_BUILTIN_PORT, String(port))
  274 + moeTcp.sendHexStr({ message: hexStr })
  275 + setTimeout(() => {
  276 + try { moeTcp.disconnect() } catch (_) {}
  277 + resolve()
  278 + }, 300)
  279 + } catch (e) {
232 280 try { moeTcp.disconnect() } catch (_) {}
233   - resolve()
234   - }, 300)
235   - } catch (e) {
236   - reject(e)
237   - }
  281 + tryPort(idx + 1).then(resolve).catch(reject)
  282 + }
  283 + })
238 284 })
239   - })
  285 + }
  286 + return tryPort(0)
240 287 } catch (e) {
241 288 return Promise.reject(e)
242 289 }
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/protocols/escPosBuilder.ts 0 → 100644
  1 +import type { LabelPrintPayload } from '../types/printer'
  2 +
  3 +function stringToBytes (str: string): number[] {
  4 + const out: number[] = []
  5 + for (let i = 0; i < str.length; i++) {
  6 + let c = str.charCodeAt(i)
  7 + if (c < 0x80) {
  8 + out.push(c)
  9 + } else if (c < 0x800) {
  10 + out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f))
  11 + } else if (c < 0xd800 || c >= 0xe000) {
  12 + out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f))
  13 + } else {
  14 + i++
  15 + const c2 = str.charCodeAt(i)
  16 + const u = ((c & 0x3ff) << 10) + (c2 & 0x3ff) + 0x10000
  17 + out.push(
  18 + 0xf0 | (u >> 18),
  19 + 0x80 | ((u >> 12) & 0x3f),
  20 + 0x80 | ((u >> 6) & 0x3f),
  21 + 0x80 | (u & 0x3f)
  22 + )
  23 + }
  24 + }
  25 + return out
  26 +}
  27 +
  28 +function appendText (out: number[], text: string) {
  29 + const bytes = stringToBytes(text)
  30 + for (let i = 0; i < bytes.length; i++) out.push(bytes[i])
  31 +}
  32 +
  33 +function appendLine (out: number[], text = '') {
  34 + appendText(out, text)
  35 + out.push(0x0a)
  36 +}
  37 +
  38 +function appendAlign (out: number[], align: 0 | 1 | 2) {
  39 + out.push(0x1b, 0x61, align)
  40 +}
  41 +
  42 +function appendBold (out: number[], bold: boolean) {
  43 + out.push(0x1b, 0x45, bold ? 1 : 0)
  44 +}
  45 +
  46 +function appendSize (out: number[], width = 0, height = 0) {
  47 + const value = ((width & 0x07) << 4) | (height & 0x07)
  48 + out.push(0x1d, 0x21, value)
  49 +}
  50 +
  51 +function createEscDocument (builder: (out: number[]) => void): number[] {
  52 + const out: number[] = []
  53 + out.push(0x1b, 0x40)
  54 + builder(out)
  55 + out.push(0x1b, 0x64, 0x04)
  56 + return out
  57 +}
  58 +
  59 +export function buildEscPosTestPrintData (): number[] {
  60 + return createEscDocument((out) => {
  61 + appendAlign(out, 1)
  62 + appendBold(out, true)
  63 + appendSize(out, 1, 1)
  64 + appendLine(out, 'TEST PRINT')
  65 + appendBold(out, false)
  66 + appendSize(out, 0, 0)
  67 + appendLine(out, 'GP-R3 / ESC-POS')
  68 + appendLine(out, 'Food Label System')
  69 + appendLine(out, '-----------------------------')
  70 + appendLine(out, 'Connection OK')
  71 + appendLine(out, 'Protocol OK')
  72 + appendLine(out, '-----------------------------')
  73 + })
  74 +}
  75 +
  76 +export function buildEscPosLabelData (payload: LabelPrintPayload): number[] {
  77 + const {
  78 + productName,
  79 + labelId,
  80 + printQty = 1,
  81 + category = '',
  82 + extraLine = '',
  83 + } = payload
  84 +
  85 + return createEscDocument((out) => {
  86 + appendAlign(out, 1)
  87 + appendBold(out, true)
  88 + appendSize(out, 1, 1)
  89 + appendLine(out, 'FOOD LABEL')
  90 + appendBold(out, false)
  91 + appendSize(out, 0, 0)
  92 + appendLine(out, '-----------------------------')
  93 + appendLine(out, 'Product: ' + productName)
  94 + if (category) appendLine(out, 'Category: ' + category)
  95 + appendLine(out, 'Label ID: ' + labelId)
  96 + if (extraLine) appendLine(out, extraLine)
  97 + appendLine(out, 'Qty: ' + String(printQty))
  98 + appendLine(out, '-----------------------------')
  99 + })
  100 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/protocols/tscProtocol.ts 0 → 100644
  1 +import type { LabelPrintPayload } from '../types/printer'
  2 +import { buildTestTscLabel, buildTscLabel } from '../tscLabelBuilder'
  3 +
  4 +export function buildTscTestPrintData (): number[] {
  5 + return buildTestTscLabel()
  6 +}
  7 +
  8 +export function buildTscLabelData (payload: LabelPrintPayload): number[] {
  9 + return buildTscLabel(payload)
  10 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts 0 → 100644
  1 +export type PrinterType = 'bluetooth' | 'builtin'
  2 +export type BtDeviceType = 'ble' | 'classic' | 'dual' | 'unknown'
  3 +export type ActiveBtDeviceType = 'ble' | 'classic'
  4 +export type PrinterProtocol = 'tsc' | 'esc'
  5 +
  6 +export interface PrinterCandidate {
  7 + deviceId: string
  8 + name: string
  9 + RSSI?: number
  10 + type?: BtDeviceType
  11 +}
  12 +
  13 +export interface LabelPrintPayload {
  14 + productName: string
  15 + labelId: string
  16 + printQty?: number
  17 + widthMm?: number
  18 + heightMm?: number
  19 + category?: string
  20 + extraLine?: string
  21 +}
  22 +
  23 +export interface PrinterDriver {
  24 + key: string
  25 + brand: string
  26 + model: string
  27 + displayName: string
  28 + protocol: PrinterProtocol
  29 + preferredConnection?: ActiveBtDeviceType
  30 + preferredBleMtu?: number
  31 + keywords: string[]
  32 + matches: (device: PrinterCandidate) => number
  33 + resolveConnectionType: (device: PrinterCandidate) => ActiveBtDeviceType
  34 + buildTestPrintData: () => number[]
  35 + buildLabelData: (payload: LabelPrintPayload) => number[]
  36 +}
  37 +
  38 +export interface ResolvedPrinterCandidate extends PrinterCandidate {
  39 + driverKey: string
  40 + driverName: string
  41 + protocol: PrinterProtocol
  42 + resolvedType: ActiveBtDeviceType
  43 +}
  44 +
  45 +export interface CurrentPrinterSummary {
  46 + type: PrinterType | ''
  47 + displayName: string
  48 + deviceId: string
  49 + driverKey: string
  50 + driverName: string
  51 + protocol: PrinterProtocol
  52 + deviceType?: ActiveBtDeviceType | ''
  53 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacRole/RbacRoleGetOutputDto.cs
... ... @@ -5,5 +5,9 @@ namespace FoodLabeling.Application.Contracts.Dtos.RbacRole;
5 5 /// </summary>
6 6 public class RbacRoleGetOutputDto : RbacRoleGetListOutputDto
7 7 {
  8 + /// <summary>
  9 + /// 该角色已分配的菜单权限ID列表
  10 + /// </summary>
  11 + public List<string> MenuIds { get; set; } = new();
8 12 }
9 13  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberAssignedLocationDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +public class TeamMemberAssignedLocationDto
  4 +{
  5 + public string Id { get; set; } = string.Empty;
  6 +
  7 + public string LocationCode { get; set; } = string.Empty;
  8 +
  9 + public string LocationName { get; set; } = string.Empty;
  10 +}
  11 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberCreateInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +public class TeamMemberCreateInputVo
  4 +{
  5 + public string FullName { get; set; } = string.Empty;
  6 +
  7 + /// <summary>
  8 + /// 登录账号(建议用邮箱或自定义用户名)
  9 + /// </summary>
  10 + public string UserName { get; set; } = string.Empty;
  11 +
  12 + public string Password { get; set; } = string.Empty;
  13 +
  14 + public string? Email { get; set; }
  15 +
  16 + public long? Phone { get; set; }
  17 +
  18 + public Guid? RoleId { get; set; }
  19 +
  20 + /// <summary>
  21 + /// 关联门店(至少1个)
  22 + /// </summary>
  23 + public List<string> LocationIds { get; set; } = new();
  24 +
  25 + public bool State { get; set; } = true;
  26 +}
  27 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberGetListInputVo.cs 0 → 100644
  1 +using Volo.Abp.Application.Dtos;
  2 +
  3 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  4 +
  5 +/// <summary>
  6 +/// 成员分页查询入参
  7 +/// </summary>
  8 +public class TeamMemberGetListInputVo : PagedAndSortedResultRequestDto
  9 +{
  10 + /// <summary>
  11 + /// 关键字(姓名/用户名/邮箱/电话)
  12 + /// </summary>
  13 + public string? Keyword { get; set; }
  14 +
  15 + /// <summary>
  16 + /// 角色ID(可选)
  17 + /// </summary>
  18 + public Guid? RoleId { get; set; }
  19 +
  20 + /// <summary>
  21 + /// 门店ID(可选)
  22 + /// </summary>
  23 + public string? LocationId { get; set; }
  24 +
  25 + /// <summary>
  26 + /// 启用状态(可选)
  27 + /// </summary>
  28 + public bool? State { get; set; }
  29 +}
  30 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberGetListOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +public class TeamMemberGetListOutputDto
  4 +{
  5 + public Guid Id { get; set; }
  6 +
  7 + public string FullName { get; set; } = string.Empty;
  8 +
  9 + public string UserName { get; set; } = string.Empty;
  10 +
  11 + public string? Email { get; set; }
  12 +
  13 + public long? Phone { get; set; }
  14 +
  15 + public bool State { get; set; }
  16 +
  17 + /// <summary>
  18 + /// 角色(当前仅返回第一个)
  19 + /// </summary>
  20 + public Guid? RoleId { get; set; }
  21 +
  22 + public string? RoleName { get; set; }
  23 +
  24 + public List<TeamMemberAssignedLocationDto> AssignedLocations { get; set; } = new();
  25 +}
  26 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberGetOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +public class TeamMemberGetOutputDto
  4 +{
  5 + public Guid Id { get; set; }
  6 +
  7 + public string FullName { get; set; } = string.Empty;
  8 +
  9 + public string UserName { get; set; } = string.Empty;
  10 +
  11 + public string? Email { get; set; }
  12 +
  13 + public long? Phone { get; set; }
  14 +
  15 + public bool State { get; set; }
  16 +
  17 + public Guid? RoleId { get; set; }
  18 +
  19 + public List<string> LocationIds { get; set; } = new();
  20 +
  21 + public List<TeamMemberAssignedLocationDto> AssignedLocations { get; set; } = new();
  22 +}
  23 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberUpdateInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +public class TeamMemberUpdateInputVo
  4 +{
  5 + public string FullName { get; set; } = string.Empty;
  6 +
  7 + public string UserName { get; set; } = string.Empty;
  8 +
  9 + /// <summary>
  10 + /// 为空表示不改密码
  11 + /// </summary>
  12 + public string? Password { get; set; }
  13 +
  14 + public string? Email { get; set; }
  15 +
  16 + public long? Phone { get; set; }
  17 +
  18 + public Guid? RoleId { get; set; }
  19 +
  20 + public List<string> LocationIds { get; set; } = new();
  21 +
  22 + public bool State { get; set; } = true;
  23 +}
  24 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs 0 → 100644
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
  2 +using FoodLabeling.Application.Contracts.Dtos.TeamMember;
  3 +
  4 +namespace FoodLabeling.Application.Contracts.IServices;
  5 +
  6 +public interface ITeamMemberAppService
  7 +{
  8 + Task<PagedResultWithPageDto<TeamMemberGetListOutputDto>> GetListAsync(TeamMemberGetListInputVo input);
  9 +
  10 + Task<TeamMemberGetOutputDto> GetAsync(Guid id);
  11 +
  12 + Task<TeamMemberGetOutputDto> CreateAsync(TeamMemberCreateInputVo input);
  13 +
  14 + Task<TeamMemberGetOutputDto> UpdateAsync(Guid id, TeamMemberUpdateInputVo input);
  15 +
  16 + Task DeleteAsync(Guid id);
  17 +}
  18 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/RoleMenuDbEntity.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Services.DbModels;
  4 +
  5 +/// <summary>
  6 +/// rolemenu 表映射(兼容字符串类型 RoleId/MenuId)
  7 +/// </summary>
  8 +[SugarTable("rolemenu")]
  9 +public class RoleMenuDbEntity
  10 +{
  11 + [SugarColumn(IsPrimaryKey = true)]
  12 + public string Id { get; set; } = string.Empty;
  13 +
  14 + public string RoleId { get; set; } = string.Empty;
  15 +
  16 + public string MenuId { get; set; } = string.Empty;
  17 +}
  18 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/UserLocationDbEntity.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Services.DbModels;
  4 +
  5 +/// <summary>
  6 +/// userlocation 表映射(成员-门店关联)
  7 +/// </summary>
  8 +[SugarTable("userlocation")]
  9 +public class UserLocationDbEntity
  10 +{
  11 + [SugarColumn(IsPrimaryKey = true)]
  12 + public string Id { get; set; } = string.Empty;
  13 +
  14 + public bool IsDeleted { get; set; }
  15 +
  16 + public DateTime CreationTime { get; set; }
  17 +
  18 + public string? CreatorId { get; set; }
  19 +
  20 + public string? LastModifierId { get; set; }
  21 +
  22 + public DateTime? LastModificationTime { get; set; }
  23 +
  24 + public string UserId { get; set; } = string.Empty;
  25 +
  26 + public string LocationId { get; set; } = string.Empty;
  27 +
  28 + public string? ConcurrencyStamp { get; set; }
  29 +}
  30 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/RbacRoleAppService.cs
1 1 using FoodLabeling.Application.Contracts.Dtos.RbacRole;
2 2 using FoodLabeling.Application.Contracts.Dtos.Common;
3 3 using FoodLabeling.Application.Contracts.IServices;
  4 +using FoodLabeling.Application.Services.DbModels;
4 5 using Microsoft.AspNetCore.Mvc;
5 6 using SqlSugar;
6 7 using Volo.Abp;
... ... @@ -17,17 +18,20 @@ namespace FoodLabeling.Application.Services;
17 18 /// </summary>
18 19 public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
19 20 {
  21 + private readonly ISqlSugarDbContext _dbContext;
20 22 private readonly ISqlSugarRepository<RoleAggregateRoot, Guid> _roleRepository;
21 23 private readonly ISqlSugarRepository<RoleMenuEntity> _roleMenuRepository;
22 24 private readonly ISqlSugarRepository<RoleDeptEntity> _roleDeptRepository;
23 25 private readonly ISqlSugarRepository<UserRoleEntity> _userRoleRepository;
24 26  
25 27 public RbacRoleAppService(
  28 + ISqlSugarDbContext dbContext,
26 29 ISqlSugarRepository<RoleAggregateRoot, Guid> roleRepository,
27 30 ISqlSugarRepository<RoleMenuEntity> roleMenuRepository,
28 31 ISqlSugarRepository<RoleDeptEntity> roleDeptRepository,
29 32 ISqlSugarRepository<UserRoleEntity> userRoleRepository)
30 33 {
  34 + _dbContext = dbContext;
31 35 _roleRepository = roleRepository;
32 36 _roleMenuRepository = roleMenuRepository;
33 37 _roleDeptRepository = roleDeptRepository;
... ... @@ -90,6 +94,11 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
90 94 throw new UserFriendlyException("角色不存在");
91 95 }
92 96  
  97 + var menuIds = await _dbContext.SqlSugarClient.Queryable<RoleMenuDbEntity>()
  98 + .Where(x => x.RoleId == id.ToString())
  99 + .Select(x => x.MenuId)
  100 + .ToListAsync();
  101 +
93 102 return new RbacRoleGetOutputDto
94 103 {
95 104 Id = entity.Id,
... ... @@ -98,7 +107,8 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
98 107 Remark = entity.Remark,
99 108 DataScope = (int)entity.DataScope,
100 109 State = entity.State,
101   - OrderNum = entity.OrderNum
  110 + OrderNum = entity.OrderNum,
  111 + MenuIds = menuIds
102 112 };
103 113 }
104 114  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs 0 → 100644
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
  2 +using FoodLabeling.Application.Contracts.Dtos.TeamMember;
  3 +using FoodLabeling.Application.Contracts.IServices;
  4 +using FoodLabeling.Application.Services.DbModels;
  5 +using FoodLabeling.Domain.Entities;
  6 +using SqlSugar;
  7 +using Volo.Abp;
  8 +using Volo.Abp.Application.Services;
  9 +using Volo.Abp.Domain.Entities;
  10 +using Volo.Abp.Guids;
  11 +using Yi.Framework.Rbac.Domain.Entities;
  12 +using Yi.Framework.Rbac.Domain.Managers;
  13 +using Yi.Framework.SqlSugarCore.Abstractions;
  14 +
  15 +namespace FoodLabeling.Application.Services;
  16 +
  17 +/// <summary>
  18 +/// 成员(Team Member/Manager)服务,对外仅在 food-labeling-us 暴露
  19 +/// </summary>
  20 +public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
  21 +{
  22 + private readonly ISqlSugarRepository<UserAggregateRoot, Guid> _userRepository;
  23 + private readonly UserManager _userManager;
  24 + private readonly ISqlSugarDbContext _dbContext;
  25 + private readonly IGuidGenerator _guidGenerator;
  26 +
  27 + public TeamMemberAppService(
  28 + ISqlSugarRepository<UserAggregateRoot, Guid> userRepository,
  29 + UserManager userManager,
  30 + ISqlSugarDbContext dbContext,
  31 + IGuidGenerator guidGenerator)
  32 + {
  33 + _userRepository = userRepository;
  34 + _userManager = userManager;
  35 + _dbContext = dbContext;
  36 + _guidGenerator = guidGenerator;
  37 + }
  38 +
  39 + /// <summary>
  40 + /// 成员分页列表(含角色与已分配门店)
  41 + /// </summary>
  42 + public async Task<PagedResultWithPageDto<TeamMemberGetListOutputDto>> GetListAsync(TeamMemberGetListInputVo input)
  43 + {
  44 + var pageIndex = input.SkipCount / input.MaxResultCount + 1;
  45 + var pageSize = input.MaxResultCount;
  46 + var keyword = input.Keyword?.Trim();
  47 +
  48 + RefAsync<int> total = 0;
  49 +
  50 + // 先按 user 表筛选分页,再批量补齐角色与门店
  51 + var users = await _userRepository._DbQueryable
  52 + .Where(u => !u.IsDeleted)
  53 + .WhereIF(!string.IsNullOrWhiteSpace(keyword),
  54 + u => (u.Name != null && u.Name.Contains(keyword!)) ||
  55 + u.UserName.Contains(keyword!) ||
  56 + (u.Email != null && u.Email.Contains(keyword!)) ||
  57 + (u.Phone != null && u.Phone.ToString()!.Contains(keyword!)))
  58 + .WhereIF(input.State != null, u => u.State == input.State)
  59 + .OrderByIF(!string.IsNullOrWhiteSpace(input.Sorting), input.Sorting!)
  60 + .OrderByDescending(u => u.CreationTime)
  61 + .ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
  62 +
  63 + var userIds = users.Select(x => x.Id).ToList();
  64 + var userIdStrings = userIds.Select(x => x.ToString()).ToList();
  65 +
  66 + // user-role: 仅取第一个角色(原型表格展示单角色)
  67 + var userRolePairs = await _dbContext.SqlSugarClient.Queryable<UserRoleEntity, RoleAggregateRoot>((ur, r) => ur.RoleId == r.Id)
  68 + .Where(ur => userIds.Contains(ur.UserId))
  69 + .Select((ur, r) => new { ur.UserId, r.Id, r.RoleName })
  70 + .ToListAsync();
  71 +
  72 + var roleMap = userRolePairs
  73 + .GroupBy(x => x.UserId)
  74 + .ToDictionary(g => g.Key, g => g.FirstOrDefault());
  75 +
  76 + // user-location
  77 + var userLocations = await _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
  78 + .Where(x => !x.IsDeleted)
  79 + .WhereIF(!string.IsNullOrWhiteSpace(input.LocationId), x => x.LocationId == input.LocationId)
  80 + .Where(x => userIdStrings.Contains(x.UserId))
  81 + .ToListAsync();
  82 +
  83 + // 如果按 LocationId 过滤,需要反向过滤掉无关联的 user
  84 + if (!string.IsNullOrWhiteSpace(input.LocationId))
  85 + {
  86 + var allowedUserIds = userLocations.Select(x => x.UserId).ToHashSet();
  87 + users = users.Where(u => allowedUserIds.Contains(u.Id.ToString())).ToList();
  88 + }
  89 +
  90 + var locationIds = userLocations.Select(x => x.LocationId).Distinct().ToList();
  91 + var locations = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  92 + .Where(x => !x.IsDeleted)
  93 + .WhereIF(locationIds.Count > 0, x => locationIds.Contains(x.Id.ToString()))
  94 + .Select(x => new { x.Id, x.LocationCode, x.LocationName })
  95 + .ToListAsync();
  96 + var locationMap = locations.ToDictionary(x => x.Id.ToString(), x => x);
  97 +
  98 + var assignedMap = userLocations
  99 + .GroupBy(x => x.UserId)
  100 + .ToDictionary(
  101 + g => g.Key,
  102 + g => g.Select(x =>
  103 + {
  104 + if (locationMap.TryGetValue(x.LocationId, out var loc))
  105 + {
  106 + return new TeamMemberAssignedLocationDto
  107 + {
  108 + Id = loc.Id.ToString(),
  109 + LocationCode = loc.LocationCode,
  110 + LocationName = loc.LocationName
  111 + };
  112 + }
  113 + return null;
  114 + }).Where(x => x != null).Cast<TeamMemberAssignedLocationDto>().ToList());
  115 +
  116 + var items = users.Select(u =>
  117 + {
  118 + roleMap.TryGetValue(u.Id, out var role);
  119 + assignedMap.TryGetValue(u.Id.ToString(), out var assigned);
  120 +
  121 + return new TeamMemberGetListOutputDto
  122 + {
  123 + Id = u.Id,
  124 + FullName = u.Name ?? string.Empty,
  125 + UserName = u.UserName,
  126 + Email = u.Email,
  127 + Phone = u.Phone,
  128 + State = u.State,
  129 + RoleId = role?.Id,
  130 + RoleName = role?.RoleName,
  131 + AssignedLocations = assigned ?? new List<TeamMemberAssignedLocationDto>()
  132 + };
  133 + }).ToList();
  134 +
  135 + var totalCount = (long)total;
  136 + return new PagedResultWithPageDto<TeamMemberGetListOutputDto>
  137 + {
  138 + PageIndex = pageIndex,
  139 + PageSize = pageSize,
  140 + TotalCount = totalCount,
  141 + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
  142 + Items = items
  143 + };
  144 + }
  145 +
  146 + /// <summary>
  147 + /// 成员详情(带门店ID列表)
  148 + /// </summary>
  149 + public async Task<TeamMemberGetOutputDto> GetAsync(Guid id)
  150 + {
  151 + var user = await _userRepository.GetByIdAsync(id);
  152 + if (user is null || user.IsDeleted)
  153 + {
  154 + throw new UserFriendlyException("成员不存在");
  155 + }
  156 +
  157 + var userIdString = id.ToString();
  158 + var links = await _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
  159 + .Where(x => !x.IsDeleted && x.UserId == userIdString)
  160 + .ToListAsync();
  161 +
  162 + var locationIds = links.Select(x => x.LocationId).Distinct().ToList();
  163 + var locations = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  164 + .Where(x => !x.IsDeleted)
  165 + .WhereIF(locationIds.Count > 0, x => locationIds.Contains(x.Id.ToString()))
  166 + .Select(x => new { x.Id, x.LocationCode, x.LocationName })
  167 + .ToListAsync();
  168 +
  169 + var assigned = locations.Select(x => new TeamMemberAssignedLocationDto
  170 + {
  171 + Id = x.Id.ToString(),
  172 + LocationCode = x.LocationCode,
  173 + LocationName = x.LocationName
  174 + }).ToList();
  175 +
  176 + var role = await _dbContext.SqlSugarClient.Queryable<UserRoleEntity>().FirstAsync(x => x.UserId == id);
  177 +
  178 + return new TeamMemberGetOutputDto
  179 + {
  180 + Id = user.Id,
  181 + FullName = user.Name ?? string.Empty,
  182 + UserName = user.UserName,
  183 + Email = user.Email,
  184 + Phone = user.Phone,
  185 + State = user.State,
  186 + RoleId = role?.RoleId,
  187 + LocationIds = locationIds,
  188 + AssignedLocations = assigned
  189 + };
  190 + }
  191 +
  192 + /// <summary>
  193 + /// 新增成员(同步设置角色与门店)
  194 + /// </summary>
  195 + public async Task<TeamMemberGetOutputDto> CreateAsync(TeamMemberCreateInputVo input)
  196 + {
  197 + if (input.LocationIds is null || input.LocationIds.Count == 0)
  198 + {
  199 + throw new UserFriendlyException("成员必须至少分配一个门店");
  200 + }
  201 +
  202 + var user = new UserAggregateRoot(input.UserName.Trim(), input.Password, input.Phone, input.FullName.Trim())
  203 + {
  204 + Name = input.FullName.Trim(),
  205 + Email = input.Email?.Trim(),
  206 + State = input.State
  207 + };
  208 +
  209 + EntityHelper.TrySetId(user, _guidGenerator.Create);
  210 + user.BuildPassword();
  211 +
  212 + await _userManager.CreateAsync(user);
  213 +
  214 + if (input.RoleId != null)
  215 + {
  216 + await _userManager.GiveUserSetRoleAsync(new List<Guid> { user.Id }, new List<Guid> { input.RoleId.Value });
  217 + }
  218 +
  219 + await UpsertUserLocationsAsync(user.Id, input.LocationIds);
  220 +
  221 + return await GetAsync(user.Id);
  222 + }
  223 +
  224 + /// <summary>
  225 + /// 编辑成员(同步设置角色与门店)
  226 + /// </summary>
  227 + public async Task<TeamMemberGetOutputDto> UpdateAsync(Guid id, TeamMemberUpdateInputVo input)
  228 + {
  229 + if (input.LocationIds is null || input.LocationIds.Count == 0)
  230 + {
  231 + throw new UserFriendlyException("成员必须至少分配一个门店");
  232 + }
  233 +
  234 + var user = await _userRepository.GetByIdAsync(id);
  235 + if (user is null || user.IsDeleted)
  236 + {
  237 + throw new UserFriendlyException("成员不存在");
  238 + }
  239 +
  240 + user.Name = input.FullName.Trim();
  241 + user.UserName = input.UserName.Trim();
  242 + user.Email = input.Email?.Trim();
  243 + user.Phone = input.Phone;
  244 + user.State = input.State;
  245 +
  246 + if (!string.IsNullOrWhiteSpace(input.Password))
  247 + {
  248 + user.EncryPassword.Password = input.Password;
  249 + user.BuildPassword();
  250 + }
  251 +
  252 + await _userRepository.UpdateAsync(user);
  253 +
  254 + // 角色:覆盖式设置(只保留一个)
  255 + if (input.RoleId != null)
  256 + {
  257 + await _userManager.GiveUserSetRoleAsync(new List<Guid> { id }, new List<Guid> { input.RoleId.Value });
  258 + }
  259 + else
  260 + {
  261 + await _userManager.GiveUserSetRoleAsync(new List<Guid> { id }, new List<Guid>());
  262 + }
  263 +
  264 + await UpsertUserLocationsAsync(id, input.LocationIds);
  265 +
  266 + return await GetAsync(id);
  267 + }
  268 +
  269 + /// <summary>
  270 + /// 删除成员(逻辑删除 user;并逻辑删除关联表)
  271 + /// </summary>
  272 + public async Task DeleteAsync(Guid id)
  273 + {
  274 + var user = await _userRepository.GetByIdAsync(id);
  275 + if (user is null || user.IsDeleted)
  276 + {
  277 + return;
  278 + }
  279 +
  280 + user.IsDeleted = true;
  281 + await _userRepository.UpdateAsync(user);
  282 +
  283 + var userIdString = id.ToString();
  284 + var currentUserId = CurrentUser?.Id?.ToString();
  285 + await _dbContext.SqlSugarClient.Updateable<UserLocationDbEntity>()
  286 + .SetColumns(x => new UserLocationDbEntity
  287 + {
  288 + IsDeleted = true,
  289 + LastModificationTime = DateTime.Now,
  290 + LastModifierId = currentUserId
  291 + })
  292 + .Where(x => x.UserId == userIdString && !x.IsDeleted)
  293 + .ExecuteCommandAsync();
  294 + }
  295 +
  296 + private async Task UpsertUserLocationsAsync(Guid userId, List<string> locationIds)
  297 + {
  298 + var now = DateTime.Now;
  299 + var userIdString = userId.ToString();
  300 + var wanted = locationIds.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().ToList();
  301 + var currentUserId = CurrentUser?.Id?.ToString();
  302 +
  303 + // 校验门店存在且未删除
  304 + var validCount = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  305 + .Where(x => !x.IsDeleted)
  306 + .Where(x => wanted.Contains(x.Id.ToString()))
  307 + .CountAsync();
  308 + if (validCount != wanted.Count)
  309 + {
  310 + throw new UserFriendlyException("存在无效门店,请刷新后重试");
  311 + }
  312 +
  313 + var existing = await _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
  314 + .Where(x => x.UserId == userIdString)
  315 + .ToListAsync();
  316 +
  317 + var existingActive = existing.Where(x => !x.IsDeleted).ToList();
  318 + var existingActiveSet = existingActive.Select(x => x.LocationId).ToHashSet();
  319 +
  320 + // 需要删除的(逻辑删除)
  321 + var toDelete = existingActive.Where(x => !wanted.Contains(x.LocationId)).ToList();
  322 + if (toDelete.Count > 0)
  323 + {
  324 + var ids = toDelete.Select(x => x.Id).ToList();
  325 + await _dbContext.SqlSugarClient.Updateable<UserLocationDbEntity>()
  326 + .SetColumns(x => new UserLocationDbEntity
  327 + {
  328 + IsDeleted = true,
  329 + LastModificationTime = now,
  330 + LastModifierId = currentUserId
  331 + })
  332 + .Where(x => ids.Contains(x.Id))
  333 + .ExecuteCommandAsync();
  334 + }
  335 +
  336 + // 需要新增的
  337 + var toInsert = wanted.Where(x => !existingActiveSet.Contains(x)).ToList();
  338 + if (toInsert.Count > 0)
  339 + {
  340 + var rows = toInsert.Select(locationId => new UserLocationDbEntity
  341 + {
  342 + Id = _guidGenerator.Create().ToString(),
  343 + IsDeleted = false,
  344 + CreationTime = now,
  345 + CreatorId = currentUserId,
  346 + UserId = userIdString,
  347 + LocationId = locationId,
  348 + ConcurrencyStamp = string.Empty
  349 + }).ToList();
  350 +
  351 + await _dbContext.SqlSugarClient.Insertable(rows).ExecuteCommandAsync();
  352 + }
  353 + }
  354 +}
  355 +
... ...