Barkod ve Image Capture özelliği
This commit is contained in:
parent
722decd040
commit
6af68e6ef5
32 changed files with 1120 additions and 103 deletions
|
|
@ -774,7 +774,7 @@ sessizce yok sayılır:
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GridBoxOptionsDto` | `dxGridBox` | `Columns[]` (ilk kolon `Key` ile aynı adda olmalı), `SelectionMode` (`"single"`), `FilterRowVisible` (`false`), `AcceptCustomValue` (`false`), `ShowClearButton` (`true`), `Height` (`250`), `Width` (`500`) |
|
| `GridBoxOptionsDto` | `dxGridBox` | `Columns[]` (ilk kolon `Key` ile aynı adda olmalı), `SelectionMode` (`"single"`), `FilterRowVisible` (`false`), `AcceptCustomValue` (`false`), `ShowClearButton` (`true`), `Height` (`250`), `Width` (`500`) |
|
||||||
| `TagBoxOptionsDto` | `dxTagBox` | `ShowSelectionControls` (`true`), `MaxDisplayedTags` (`3`), `ShowMultiTagOnly` (`false`), `ApplyValueMode` (`"useButtons"`), `SearchEnabled` (`true`), `AcceptCustomValue` (`false`), `ShowClearButton` (`true`) — değer tek kolonda `\|` ile saklanır, bkz. §4.3 |
|
| `TagBoxOptionsDto` | `dxTagBox` | `ShowSelectionControls` (`true`), `MaxDisplayedTags` (`3`), `ShowMultiTagOnly` (`false`), `ApplyValueMode` (`"useButtons"`), `SearchEnabled` (`true`), `AcceptCustomValue` (`false`), `ShowClearButton` (`true`) — değer tek kolonda `\|` ile saklanır, bkz. §4.3 |
|
||||||
| `ImageUploadOptionsDto` | `dxImageUpload` · `dxImageViewer` | `UploadUrl`, `Accept`, `Multiple`, `MaxFileSize` (bayt), `Width`, `Height` — hepsi nullable, varsayılanı yok |
|
| `ImageUploadOptionsDto` | `dxImageUpload` · `dxImageViewer` | `UploadUrl`, `Accept`, `Multiple`, `MaxFileSize` (bayt), `Width`, `Height`, `Capture` (`environment`/`user` — mobilde dosya seçici yerine **yalnızca** kamerayı açar; boş bırakılırsa dokunmatik cihazda dosya seçicinin yanına ayrı bir kamera butonu gelir) — hepsi nullable, varsayılanı yok |
|
||||||
|
|
||||||
Çözüm **yalnızca alanın `EditorType2` değeri eşleşiyorsa** yapılır: `dxTextBox` bir alana
|
Çözüm **yalnızca alanın `EditorType2` değeri eşleşiyorsa** yapılır: `dxTextBox` bir alana
|
||||||
`columns` yazmak `GridBoxOptions`'ı doldurmaz. Ayrıca `ListFormField.EditorOptions`'ın ham
|
`columns` yazmak `GridBoxOptions`'ı doldurmaz. Ayrıca `ListFormField.EditorOptions`'ın ham
|
||||||
|
|
@ -1017,6 +1017,7 @@ alır):
|
||||||
| `Knob` | `value`/`defaultValue` (`0`), `min`/`max` (`0/100`), `step` (`1`), `size` (`100`), `strokeWidth` (`14`), `valueColor`/`rangeColor`/`textColor`, `valueTemplate` (`'{value}'`), `showValue` (`true`), `readOnly`, `disabled`, `name` |
|
| `Knob` | `value`/`defaultValue` (`0`), `min`/`max` (`0/100`), `step` (`1`), `size` (`100`), `strokeWidth` (`14`), `valueColor`/`rangeColor`/`textColor`, `valueTemplate` (`'{value}'`), `showValue` (`true`), `readOnly`, `disabled`, `name` |
|
||||||
| `Rate` | `count` (`5`), `value`/`defaultValue`, `allowHalf` (`false`), `allowClear` (`true`), `color` (`amber`), `size` (`20`), `gap` (`4`), `tooltips[]`, `disabled`, `readOnly` |
|
| `Rate` | `count` (`5`), `value`/`defaultValue`, `allowHalf` (`false`), `allowClear` (`true`), `color` (`amber`), `size` (`20`), `gap` (`4`), `tooltips[]`, `disabled`, `readOnly` |
|
||||||
| `Upload` | `accept`, `multiple`, `draggable` (`false`), `uploadLimit`, `showList` (`true`), `tip`, `fileList[]`, `disabled` — dosyanın **gerçek** yüklenmesi `beforeUpload`/`onChange` script'inde `api.post` ile yapılır |
|
| `Upload` | `accept`, `multiple`, `draggable` (`false`), `uploadLimit`, `showList` (`true`), `tip`, `fileList[]`, `disabled` — dosyanın **gerçek** yüklenmesi `beforeUpload`/`onChange` script'inde `api.post` ile yapılır |
|
||||||
|
| `BarcodeScanField` | `label`, `name` (`'barcode'`), `placeholder`, `buttonText`, `continuous` (`false` — açıkken diyalog okumadan sonra kapanmaz), `readOnly` (`true`), `className`; **olay**: `onScan(event)` — `event` okunan kodun metin hâli (Event workspace'te Input'un `onChange`'i gibi düzenlenir). Kamerayı `BarcodeDetector` (Android/Chromium) ya da lazy `zxing-wasm` (iOS/Firefox) ile okur — 1D + QR. **HTTPS şart**; iOS'ta yüklü PWA içinde yalnız 16.4+ çalışır, bileşen aksini kullanıcıya söyler |
|
||||||
|
|
||||||
**Gösterim/geri bildirim bileşenleri:**
|
**Gösterim/geri bildirim bileşenleri:**
|
||||||
|
|
||||||
|
|
|
||||||
16
.github/instructions/lowcode.instructions.md
vendored
16
.github/instructions/lowcode.instructions.md
vendored
|
|
@ -806,7 +806,7 @@ anahtarları listeler; ortak anahtarlar (§4.3.3) her editörde geçerlidir.
|
||||||
| `dxSlider` | Tek değerli kaydırıcı | Sayı | `min`, `max`, `tooltip.showMode` |
|
| `dxSlider` | Tek değerli kaydırıcı | Sayı | `min`, `max`, `tooltip.showMode` |
|
||||||
| `dxRangeSlider` | Aralık kaydırıcı | Sayı | `min`, `max`, `showRange` |
|
| `dxRangeSlider` | Aralık kaydırıcı | Sayı | `min`, `max`, `showRange` |
|
||||||
| `dxHtmlEditor` | Zengin metin | String (16) | `toolbar.items`, `toolbar.multiline`, `valueType` (`html`/`markdown`), `mediaResizing.enabled`, `imageUpload.uploadUrl`, `imageUpload.fileUploadMode` |
|
| `dxHtmlEditor` | Zengin metin | String (16) | `toolbar.items`, `toolbar.multiline`, `valueType` (`html`/`markdown`), `mediaResizing.enabled`, `imageUpload.uploadUrl`, `imageUpload.fileUploadMode` |
|
||||||
| `dxImageUpload` | Görsel yükleme (platform) | String (16) | `uploadUrl`\*, `accept`\*, `multiple`\*, `maxFileSize`\*, `width`\*, `height`\* |
|
| `dxImageUpload` | Görsel yükleme (platform) | String (16) | `uploadUrl`\*, `accept`\*, `capture`\*, `multiple`\*, `maxFileSize`\*, `width`\*, `height`\* |
|
||||||
| `dxImageViewer` | Görsel gösterimi (platform) | String (16) | `width`, `height` |
|
| `dxImageViewer` | Görsel gösterimi (platform) | String (16) | `width`, `height` |
|
||||||
|
|
||||||
`\*` ile işaretli anahtarlar backend'de **tipli DTO'ya** çözülür:
|
`\*` ile işaretli anahtarlar backend'de **tipli DTO'ya** çözülür:
|
||||||
|
|
@ -1537,6 +1537,12 @@ Kurallar:
|
||||||
her diyalog `open` + `onDialogClose` prop'larını alır.
|
her diyalog `open` + `onDialogClose` prop'larını alır.
|
||||||
Kendi ekranını açmak istiyorsan **Adres** modunu kullan (custom component route'u ya da
|
Kendi ekranını açmak istiyorsan **Adres** modunu kullan (custom component route'u ya da
|
||||||
`/admin/list/{ListFormCode}`).
|
`/admin/list/{ListFormCode}`).
|
||||||
|
- `DialogName: "BarcodeScanDialog"` **toolbar butonuna özeldir** (`ButtonPosition: 1`) ve sürekli
|
||||||
|
barkod taramasını açar: okunan her kod listeye yeni bir satır olarak eklenir.
|
||||||
|
`DialogParameters`'ta `field` zorunludur (barkodun yazılacağı kolon); kalan anahtarlar her
|
||||||
|
satıra sabit değer olarak yazılır (`{"field":"Barcode","WarehouseId":"1"}`). Satır ekleme
|
||||||
|
ekranın kendi `InsertServiceAddress` ucundan geçer — yetki ve kiracı izolasyonu normal satır
|
||||||
|
eklemeyle aynıdır. Kamera HTTPS ister.
|
||||||
- Onay akışının kendi butonları (Onayla/Reddet) `WorkflowJson` üzerinden **otomatik** gelir;
|
- Onay akışının kendi butonları (Onayla/Reddet) `WorkflowJson` üzerinden **otomatik** gelir;
|
||||||
onları `CommandColumnJson` ile tekrar tanımlama.
|
onları `CommandColumnJson` ile tekrar tanımlama.
|
||||||
|
|
||||||
|
|
@ -1719,6 +1725,7 @@ bağlıdır.
|
||||||
| interaction | `ask` | message | `if (!ask('Emin misiniz?')) return` — vazgeçilirse alan eski değerine döner ve script durur |
|
| interaction | `ask` | message | `if (!ask('Emin misiniz?')) return` — vazgeçilirse alan eski değerine döner ve script durur |
|
||||||
| interaction | `openUrl` | url, target? | `openUrl('/report?id={Id}')` · `_self` verilirse ikinci argüman eklenir |
|
| interaction | `openUrl` | url, target? | `openUrl('/report?id={Id}')` · `_self` verilirse ikinci argüman eklenir |
|
||||||
| integration | `apiToField` | target, url, path? | `set('T', await api('/api/x/{value}', 'data.name'))` |
|
| integration | `apiToField` | target, url, path? | `set('T', await api('/api/x/{value}', 'data.name'))` |
|
||||||
|
| integration | `location` | lat, lng, accuracy? | `await geoTo('Enlem', 'Boylam')` — cihaz konumunu alanlara yazar; HTTPS ve kullanıcı izni ister |
|
||||||
| integration | `custom` | code | Satırın kendisi. Son çare; sondaki `;` atılır |
|
| integration | `custom` | code | Satırın kendisi. Son çare; sondaki `;` atılır |
|
||||||
|
|
||||||
`sum` ve `clear` çoklu alan alır (`fields` listesi); diğerlerinde her parametre tek alandır.
|
`sum` ve `clear` çoklu alan alır (`fields` listesi); diğerlerinde her parametre tek alandır.
|
||||||
|
|
@ -1791,6 +1798,7 @@ Script `AsyncFunction` ile derlenir, aşağıdaki adlar parametre olarak enjekte
|
||||||
| `revert()` | Alanı değişiklik öncesi değerine döndürür (editöre de yazar) |
|
| `revert()` | Alanı değişiklik öncesi değerine döndürür (editöre de yazar) |
|
||||||
| `openUrl('/adres', '_blank')` | Yeni sekme/pencere |
|
| `openUrl('/adres', '_blank')` | Yeni sekme/pencere |
|
||||||
| `await api(url, 'data.name', options?)` | Ham `fetch`. **Kimlik doğrulama başlığı eklemez**; korumalı bir uç için Custom Endpoint tarafında erişim açık olmalıdır |
|
| `await api(url, 'data.name', options?)` | Ham `fetch`. **Kimlik doğrulama başlığı eklemez**; korumalı bir uç için Custom Endpoint tarafında erişim açık olmalıdır |
|
||||||
|
| `await geoTo('Lat', 'Lng', 'Acc'?)` | Cihaz konumunu alanlara yazar. İzin yoksa/okunamazsa alanlara **dokunmaz** ve uyarı gösterir, `false` döner |
|
||||||
| `$` | Tüm yardımcıların tek nesne hâli |
|
| `$` | Tüm yardımcıların tek nesne hâli |
|
||||||
|
|
||||||
Eski (uzun) script'ler bozulmasın diye geriye dönük uyum vardır: script içinde yardımcı adlarıyla
|
Eski (uzun) script'ler bozulmasın diye geriye dönük uyum vardır: script içinde yardımcı adlarıyla
|
||||||
|
|
@ -1841,7 +1849,7 @@ if (isReady) {
|
||||||
| copy / setValue / clear | `Copy(sourcePath, target)` · `SetValue(target, text)` · `Clear("Il", "Ilce")` |
|
| copy / setValue / clear | `Copy(sourcePath, target)` · `SetValue(target, text)` · `Clear("Il", "Ilce")` |
|
||||||
| readOnly | `ReadOnly(target, invert)` |
|
| readOnly | `ReadOnly(target, invert)` |
|
||||||
| notify / ask / openUrl | `Notify(message)` · `Ask(message)` · `OpenUrl(url, target)` |
|
| notify / ask / openUrl | `Notify(message)` · `Ask(message)` · `OpenUrl(url, target)` |
|
||||||
| apiToField / custom | `ApiToField(target, url, responsePath)` · `Custom(code)` |
|
| apiToField / location / custom | `ApiToField(target, url, responsePath)` · `Location(latField, lngField, accuracyField)` · `Custom(code)` |
|
||||||
|
|
||||||
Akıcı ekler: `.When(...)` (hepsi sağlanmalı), `.WhenAny(...)` (biri yeterli),
|
Akıcı ekler: `.When(...)` (hepsi sağlanmalı), `.WhenAny(...)` (biri yeterli),
|
||||||
`.OnChange()` / `.OnOpen()` / `.OnOpenAndChange()` / `.WithTrigger(EditorScriptTrigger.X)`,
|
`.OnChange()` / `.OnOpen()` / `.OnOpenAndChange()` / `.WithTrigger(EditorScriptTrigger.X)`,
|
||||||
|
|
@ -1938,7 +1946,7 @@ Dizi elemanları yaprak sayılmaz; `toolbar.items` tek bir `json` alanıdır.
|
||||||
| Açılır liste | `searchEnabled`\*, `searchMode`, `searchExpr`, `searchTimeout`, `minSearchLength`, `showDataBeforeSearch`, `acceptCustomValue`\*, `noDataText`, `deferRendering`, `wrapItemText`, `dropDownOptions.width/height/hideOnOutsideClick` | `dxSelectBox`, `dxLookup`, `dxTagBox`, `dxDropDownBox`, `dxGridBox`, `dxAutocomplete` |
|
| Açılır liste | `searchEnabled`\*, `searchMode`, `searchExpr`, `searchTimeout`, `minSearchLength`, `showDataBeforeSearch`, `acceptCustomValue`\*, `noDataText`, `deferRendering`, `wrapItemText`, `dropDownOptions.width/height/hideOnOutsideClick` | `dxSelectBox`, `dxLookup`, `dxTagBox`, `dxDropDownBox`, `dxGridBox`, `dxAutocomplete` |
|
||||||
| TagBox | `showSelectionControls`\*, `maxDisplayedTags`\*, `showMultiTagOnly`\*, `applyValueMode`\*, `multiline`, `hideSelectedItems` | `dxTagBox` |
|
| TagBox | `showSelectionControls`\*, `maxDisplayedTags`\*, `showMultiTagOnly`\*, `applyValueMode`\*, `multiline`, `hideSelectedItems` | `dxTagBox` |
|
||||||
| GridBox | `columns`\*, `selectionMode`\*, `filterRowVisible`\* | `dxGridBox` |
|
| GridBox | `columns`\*, `selectionMode`\*, `filterRowVisible`\* | `dxGridBox` |
|
||||||
| Görsel | `uploadUrl`\*, `fileFieldName`, `accept`\*, `multiple`\*, `maxFileSize`\* | `dxImageUpload`, `dxImageViewer` |
|
| Görsel | `uploadUrl`\*, `fileFieldName`, `accept`\*, `capture`\*, `multiple`\*, `maxFileSize`\* | `dxImageUpload`, `dxImageViewer` |
|
||||||
| Seçim | `text` (`dxCheckBox`), `switchedOnText`/`switchedOffText` (`dxSwitch`), `layout` (`dxRadioGroup`), `editAlphaChannel`/`keyStep` (`dxColorBox`) | ilgili editör |
|
| Seçim | `text` (`dxCheckBox`), `switchedOnText`/`switchedOffText` (`dxSwitch`), `layout` (`dxRadioGroup`), `editAlphaChannel`/`keyStep` (`dxColorBox`) | ilgili editör |
|
||||||
| Kaydırıcı | `min`, `max`, `step`, `tooltip.enabled`, `tooltip.showMode`, `showRange` | `dxSlider`, `dxRangeSlider` |
|
| Kaydırıcı | `min`, `max`, `step`, `tooltip.enabled`, `tooltip.showMode`, `showRange` | `dxSlider`, `dxRangeSlider` |
|
||||||
| HTML | `valueType`, `toolbar.multiline`, `toolbar.items`, `mediaResizing.enabled`, `imageUpload.fileUploadMode`, `imageUpload.uploadUrl` | `dxHtmlEditor` |
|
| HTML | `valueType`, `toolbar.multiline`, `toolbar.items`, `mediaResizing.enabled`, `imageUpload.fileUploadMode`, `imageUpload.uploadUrl` | `dxHtmlEditor` |
|
||||||
|
|
@ -1966,6 +1974,8 @@ Preset mevcut JSON ile **birleşir**, diğer ayarları silmez; aynı anahtar var
|
||||||
| Çoklu görsel | `width`, `height`, `multiple`, `accept:image/*` |
|
| Çoklu görsel | `width`, `height`, `multiple`, `accept:image/*` |
|
||||||
| HTML tam araç çubuğu | `toolbar.items` (tam liste), `toolbar.multiline`, `mediaResizing`, `imageUpload` |
|
| HTML tam araç çubuğu | `toolbar.items` (tam liste), `toolbar.multiline`, `mediaResizing`, `imageUpload` |
|
||||||
| Yükseklik 100 / 200 | `{"height":100}` · `{"height":200}` |
|
| Yükseklik 100 / 200 | `{"height":100}` · `{"height":200}` |
|
||||||
|
| Barkod okutma butonu | `buttons[]` — kamera ikonlu buton; `onClick` = `"@SCAN_BARCODE"` token'ı. Yalnız `dxTextBox`. Token'ı `editingForm.ts` kamera diyaloğuna bağlar, okunan kod alana yazılır. HTTPS şart. |
|
||||||
|
| Kamerayla foto çek | `{"accept":"image/*","capture":"environment"}` — yalnız `dxImageUpload`. Mobilde dosya seçici yerine **yalnızca** kamera açılır (`user` = ön kamera); masaüstünde yok sayılır. Ayarlanmazsa dokunmatik cihazda dosya seçicinin yanına ayrı bir kamera butonu gelir, yani ikisi birden kullanılabilir — bu kalıp kamerayı **zorunlu** kılmak içindir. |
|
||||||
|
|
||||||
### 6.5 Seeder tarafı (C#)
|
### 6.5 Seeder tarafı (C#)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -443,7 +443,7 @@ buradaki karşılığı da güncellenmelidir.
|
||||||
|
|
||||||
| Tip | Kullanım |
|
| Tip | Kullanım |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `EditorScript` | `Multiply`, `Subtract`, `Percent`, `Sum`, `Formula`, `Today`, `Days`, `Hours`, `Copy`, `SetValue`, `Clear`, `ReadOnly`, `Notify`, `Ask`, `OpenUrl`, `ApiToField`, `Custom` → `EditorScript.Build(rules)` |
|
| `EditorScript` | `Multiply`, `Subtract`, `Percent`, `Sum`, `Formula`, `Today`, `Days`, `Hours`, `Copy`, `SetValue`, `Clear`, `ReadOnly`, `Notify`, `Ask`, `OpenUrl`, `ApiToField`, `Location`, `Custom` → `EditorScript.Build(rules)` |
|
||||||
| `EditorScriptRule` | `.When(...)` / `.WhenAny(...)` koşulları, `.OnChange()` / `.OnOpen()` / `.OnOpenAndChange()` tetikleyicisi; `string`'e örtük dönüşür |
|
| `EditorScriptRule` | `.When(...)` / `.WhenAny(...)` koşulları, `.OnChange()` / `.OnOpen()` / `.OnOpenAndChange()` tetikleyicisi; `string`'e örtük dönüşür |
|
||||||
| `EditorScriptCondition` | `Is`, `IsNot`, `Contains`, `IsEmpty`, `IsNotEmpty`, `GreaterThan`, `LessThan`, `IsTrue`, `IsFalse`, `Always` |
|
| `EditorScriptCondition` | `Is`, `IsNot`, `Contains`, `IsEmpty`, `IsNotEmpty`, `GreaterThan`, `LessThan`, `IsTrue`, `IsFalse`, `Always` |
|
||||||
| `EditorOptions` | Hazır başlangıçlar: `Disabled()`, `ReadOnly()`, `ShowClearButton()`, `Multiline(h)`, `Number(precision)`, `Percent()`, `Date()`, `DateTime()`, `Time(interval)`, `Phone()`, `Slider()`, `ImageUpload()`, `Html(h)` |
|
| `EditorOptions` | Hazır başlangıçlar: `Disabled()`, `ReadOnly()`, `ShowClearButton()`, `Multiline(h)`, `Number(precision)`, `Percent()`, `Date()`, `DateTime()`, `Time(interval)`, `Phone()`, `Slider()`, `ImageUpload()`, `Html(h)` |
|
||||||
|
|
@ -1019,8 +1019,8 @@ dosyası yoktur; liste her `npm run build` / `npm start` ile tazelenir.
|
||||||
**Katalog düğümleri.** Kanvasın doğrudan çizdiği, derlenen bileşen listesinde yer almayan
|
**Katalog düğümleri.** Kanvasın doğrudan çizdiği, derlenen bileşen listesinde yer almayan
|
||||||
bileşenler tek bir sözlükte durur (`RUNTIME_NODE_COMPONENTS`): kanvas onu `VisualCanvas` içinde,
|
bileşenler tek bir sözlükte durur (`RUNTIME_NODE_COMPONENTS`): kanvas onu `VisualCanvas` içinde,
|
||||||
üretilen kod ise runtime kapsamı üzerinden kullanır — iki taraf aynı kaynaktan beslendiği için
|
üretilen kod ise runtime kapsamı üzerinden kullanır — iki taraf aynı kaynaktan beslendiği için
|
||||||
tasarım ve çalışma zamanı ayrışamaz. Sözlükte `PlatformIcon` ve public site parçaları
|
tasarım ve çalışma zamanı ayrışamaz. Sözlükte `PlatformIcon`, `BarcodeScanField`
|
||||||
(`components/publicSite`) bulunur. Yeni bir düğüm eklerken: bileşen `components/publicSite` (ya da
|
(`components/shared/BarcodeScanner`) ve public site parçaları (`components/publicSite`) bulunur. Yeni bir düğüm eklerken: bileşen `components/publicSite` (ya da
|
||||||
uygun klasör) → `runtimeNodes.ts` sözlüğü → `catalog.ts` tanımı (`kind: 'custom'`) → `LanguagesData.json`
|
uygun klasör) → `runtimeNodes.ts` sözlüğü → `catalog.ts` tanımı (`kind: 'custom'`) → `LanguagesData.json`
|
||||||
açıklama anahtarı.
|
açıklama anahtarı.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,10 @@ public class ImageUploadOptionsDto
|
||||||
public long? MaxFileSize { get; set; }
|
public long? MaxFileSize { get; set; }
|
||||||
public int? Width { get; set; }
|
public int? Width { get; set; }
|
||||||
public int? Height { get; set; }
|
public int? Height { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mobil cihazda dosya secici yerine dogrudan kamerayi acar:
|
||||||
|
/// "environment" arka, "user" on kamera. Bos birakilirsa dosya secici acilir.
|
||||||
|
/// </summary>
|
||||||
|
public string Capture { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28895,6 +28895,198 @@
|
||||||
"key": "App.Listform.ListformField.OrderId",
|
"key": "App.Listform.ListformField.OrderId",
|
||||||
"en": "Order",
|
"en": "Order",
|
||||||
"tr": "Sipariş"
|
"tr": "Sipariş"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.BarcodeScanner.Title",
|
||||||
|
"en": "Scan barcode",
|
||||||
|
"tr": "Barkod okut"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.BarcodeScanner.Starting",
|
||||||
|
"en": "Starting the camera...",
|
||||||
|
"tr": "Kamera açılıyor..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.BarcodeScanner.Aim",
|
||||||
|
"en": "Hold the barcode inside the frame.",
|
||||||
|
"tr": "Barkodu çerçevenin içinde tutun."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.BarcodeScanner.PermissionDenied",
|
||||||
|
"en": "Camera permission was denied. Allow camera access for this site and try again.",
|
||||||
|
"tr": "Kamera izni verilmedi. Bu site için kamera erişimine izin verip tekrar deneyin."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.BarcodeScanner.InsecureContext",
|
||||||
|
"en": "The camera is only available over HTTPS.",
|
||||||
|
"tr": "Kamera yalnızca HTTPS üzerinden kullanılabilir."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.BarcodeScanner.StandaloneIos",
|
||||||
|
"en": "On this iOS version the camera does not work in the installed app. Open the page in Safari.",
|
||||||
|
"tr": "Bu iOS sürümünde kamera yüklü uygulama içinde çalışmıyor. Sayfayı Safari'de açın."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.BarcodeScanner.CameraError",
|
||||||
|
"en": "The camera could not be started.",
|
||||||
|
"tr": "Kamera başlatılamadı."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.BarcodeScanner.SaveFailed",
|
||||||
|
"en": "The scanned code could not be saved.",
|
||||||
|
"tr": "Okunan kod kaydedilemedi."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.BarcodeScanner.ScannedCount",
|
||||||
|
"en": "Scanned",
|
||||||
|
"tr": "Okunan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.BarcodeScanner.NotConfigured",
|
||||||
|
"en": "The barcode button is not configured: a target field and an insert address are required.",
|
||||||
|
"tr": "Barkod butonu yapılandırılmamış: hedef alan ve ekleme adresi gerekli."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.BarcodeScanner.Placeholder",
|
||||||
|
"en": "Scan a barcode",
|
||||||
|
"tr": "Barkod okutun"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.BarcodeScanner.Scan",
|
||||||
|
"en": "Scan",
|
||||||
|
"tr": "Okut"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.PresetBarcodeScan.BarcodeScanLabel",
|
||||||
|
"en": "Barcode scan button",
|
||||||
|
"tr": "Barkod okutma butonu"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.PresetBarcodeScan.BarcodeScanDescription",
|
||||||
|
"en": "Adds a camera button next to the text box; the scanned code is written into the field.",
|
||||||
|
"tr": "Metin kutusunun yanına kamera butonu ekler; okunan kod alana yazılır."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.CatalogBarcodeScanField.BarcodeScanFieldDescription",
|
||||||
|
"en": "Text field with a camera button that reads barcodes and QR codes.",
|
||||||
|
"tr": "Barkod ve QR okuyan kamera butonlu metin alanı."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.Geolocation.Unsupported",
|
||||||
|
"en": "This device does not provide location information.",
|
||||||
|
"tr": "Bu cihaz konum bilgisi vermiyor."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.Geolocation.Failed",
|
||||||
|
"en": "Location could not be read. Allow location access and try again.",
|
||||||
|
"tr": "Konum okunamadı. Konum erişimine izin verip tekrar deneyin."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ScriptBuilderLocation.LocationLabel",
|
||||||
|
"en": "Write device location",
|
||||||
|
"tr": "Cihaz konumunu yaz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ScriptBuilderLocation.LocationSummary",
|
||||||
|
"en": "Reads the device location once and writes it into the latitude/longitude fields. Requires HTTPS and the user's permission.",
|
||||||
|
"tr": "Cihaz konumunu tek seferde okur ve enlem/boylam alanlarına yazar. HTTPS ve kullanıcı izni gerekir."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ScriptBuilderLocation.LatLabel",
|
||||||
|
"en": "Latitude field",
|
||||||
|
"tr": "Enlem alanı"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ScriptBuilderLocation.LngLabel",
|
||||||
|
"en": "Longitude field",
|
||||||
|
"tr": "Boylam alanı"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ScriptBuilderLocation.AccuracyLabel",
|
||||||
|
"en": "Accuracy field (m)",
|
||||||
|
"tr": "Doğruluk alanı (m)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ScriptBuilderLocation.AccuracyHelp",
|
||||||
|
"en": "Optional. The accuracy radius reported by the device, in metres.",
|
||||||
|
"tr": "İsteğe bağlı. Cihazın bildirdiği doğruluk yarıçapı, metre."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ScriptBuilderDescribe.Location",
|
||||||
|
"en": "Writes the device location into {lat} and {lng}.",
|
||||||
|
"tr": "Cihaz konumunu {lat} ve {lng} alanlarına yazar."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.SpecCapture.Help",
|
||||||
|
"en": "On mobile, opens the camera directly instead of the file picker. Leave empty for the file picker.",
|
||||||
|
"tr": "Mobilde dosya seçici yerine doğrudan kamerayı açar. Boş bırakılırsa dosya seçici açılır."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.SpecCapture.Environment",
|
||||||
|
"en": "Rear camera",
|
||||||
|
"tr": "Arka kamera"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.SpecCapture.User",
|
||||||
|
"en": "Front camera",
|
||||||
|
"tr": "Ön kamera"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.PresetPhotoCapture.PhotoCaptureLabel",
|
||||||
|
"en": "Take photo with camera",
|
||||||
|
"tr": "Kamerayla foto çek"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.PresetPhotoCapture.PhotoCaptureDescription",
|
||||||
|
"en": "accept: image/* + capture: environment — on mobile the rear camera opens directly.",
|
||||||
|
"tr": "accept: image/* + capture: environment — mobilde doğrudan arka kamera açılır."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.CatalogBarcodeScanField.OnScanDescription",
|
||||||
|
"en": "Runs on every successful scan; event is the scanned code as text.",
|
||||||
|
"tr": "Her başarılı okumada çalışır; event okunan kodun metin halidir."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ImageUpload.Upload",
|
||||||
|
"en": "Upload image",
|
||||||
|
"tr": "Görsel yükle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.ImageUpload.TakePhoto",
|
||||||
|
"en": "Take photo with camera",
|
||||||
|
"tr": "Kamerayla fotoğraf çek"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,14 @@ public static class EditorScript
|
||||||
public static EditorScriptRule ApiToField(string target, string url, string responsePath = null)
|
public static EditorScriptRule ApiToField(string target, string url, string responsePath = null)
|
||||||
=> new EditorScriptRule("apiToField").Set("target", target).Set("url", url).Set("path", responsePath);
|
=> new EditorScriptRule("apiToField").Set("target", target).Set("url", url).Set("path", responsePath);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cihazin konumunu alanlara yazar. accuracyField bos birakilirsa dogruluk
|
||||||
|
/// yarcapi yazilmaz. Kamera gibi konum da HTTPS ister.
|
||||||
|
/// </summary>
|
||||||
|
public static EditorScriptRule Location(string latField, string lngField, string accuracyField = null)
|
||||||
|
=> new EditorScriptRule("location")
|
||||||
|
.Set("lat", latField).Set("lng", lngField).Set("accuracy", accuracyField);
|
||||||
|
|
||||||
/// <summary>Serbest JS satiri. Runtime yardimcilari (get/set/num/...) burada da kullanilabilir.</summary>
|
/// <summary>Serbest JS satiri. Runtime yardimcilari (get/set/num/...) burada da kullanilabilir.</summary>
|
||||||
public static EditorScriptRule Custom(string code)
|
public static EditorScriptRule Custom(string code)
|
||||||
=> new EditorScriptRule("custom").Set("code", code);
|
=> new EditorScriptRule("custom").Set("code", code);
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ public sealed class EditorScriptRule
|
||||||
["ask"] = new[] { "message" },
|
["ask"] = new[] { "message" },
|
||||||
["openUrl"] = new[] { "url" },
|
["openUrl"] = new[] { "url" },
|
||||||
["apiToField"] = new[] { "target", "url" },
|
["apiToField"] = new[] { "target", "url" },
|
||||||
|
["location"] = new[] { "lat", "lng" },
|
||||||
["custom"] = new[] { "code" }
|
["custom"] = new[] { "code" }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -59,6 +60,7 @@ public sealed class EditorScriptRule
|
||||||
["ask"] = EditorScriptTrigger.Change,
|
["ask"] = EditorScriptTrigger.Change,
|
||||||
["openUrl"] = EditorScriptTrigger.Change,
|
["openUrl"] = EditorScriptTrigger.Change,
|
||||||
["apiToField"] = EditorScriptTrigger.Change,
|
["apiToField"] = EditorScriptTrigger.Change,
|
||||||
|
["location"] = EditorScriptTrigger.Change,
|
||||||
["custom"] = EditorScriptTrigger.Change
|
["custom"] = EditorScriptTrigger.Change
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -382,6 +384,10 @@ public sealed class EditorScriptRule
|
||||||
return "set(" + q(Param("target")) + ", await api(" + q(Param("url")) +
|
return "set(" + q(Param("target")) + ", await api(" + q(Param("url")) +
|
||||||
(Param("path").Length > 0 ? ", " + q(Param("path")) : string.Empty) + "))";
|
(Param("path").Length > 0 ? ", " + q(Param("path")) : string.Empty) + "))";
|
||||||
|
|
||||||
|
case "location":
|
||||||
|
return "await geoTo(" + q(Param("lat")) + ", " + q(Param("lng")) +
|
||||||
|
(Param("accuracy").Length > 0 ? ", " + q(Param("accuracy")) : string.Empty) + ")";
|
||||||
|
|
||||||
case "custom":
|
case "custom":
|
||||||
return Param("code").Trim().TrimEnd(';');
|
return Param("code").Trim().TrimEnd(';');
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
49
ui/package-lock.json
generated
49
ui/package-lock.json
generated
|
|
@ -36,7 +36,8 @@
|
||||||
"react-modal": "^3.16.3",
|
"react-modal": "^3.16.3",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.10.2",
|
||||||
"yup": "^1.7.1"
|
"yup": "^1.7.1",
|
||||||
|
"zxing-wasm": "^3.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.5",
|
"@eslint/js": "^9.39.5",
|
||||||
|
|
@ -3263,6 +3264,12 @@
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/emscripten": {
|
||||||
|
"version": "1.41.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz",
|
||||||
|
"integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
|
|
@ -11692,6 +11699,18 @@
|
||||||
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
|
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tagged-tag": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
|
|
@ -12126,6 +12145,21 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/type-fest": {
|
||||||
|
"version": "5.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz",
|
||||||
|
"integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==",
|
||||||
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"tagged-tag": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typed-array-buffer": {
|
"node_modules/typed-array-buffer": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
|
||||||
|
|
@ -13175,6 +13209,19 @@
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zxing-wasm": {
|
||||||
|
"version": "3.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.4.tgz",
|
||||||
|
"integrity": "sha512-1+v8p7sauddySVNG+ye7GD/0TS95BG2ycdkrC+EnBrFBFkHeIghzUt2yVjtx3NucJeNrQnhT8yGAIz60Vx9o0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/emscripten": "^1.41.6",
|
||||||
|
"type-fest": "^5.9.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/emscripten": ">=1.39.6"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,8 @@
|
||||||
"react-modal": "^3.16.3",
|
"react-modal": "^3.16.3",
|
||||||
"react-router-dom": "^7.18.2",
|
"react-router-dom": "^7.18.2",
|
||||||
"react-select": "^5.10.2",
|
"react-select": "^5.10.2",
|
||||||
"yup": "^1.7.1"
|
"yup": "^1.7.1",
|
||||||
|
"zxing-wasm": "^3.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.5",
|
"@eslint/js": "^9.39.5",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import PlatformIcon from './PlatformIcon'
|
import PlatformIcon from './PlatformIcon'
|
||||||
|
import { BarcodeScanField } from '@/components/shared'
|
||||||
import {
|
import {
|
||||||
PublicDemoButton,
|
PublicDemoButton,
|
||||||
PublicHeaderBar,
|
PublicHeaderBar,
|
||||||
|
|
@ -17,6 +18,7 @@ import {
|
||||||
*/
|
*/
|
||||||
export const RUNTIME_NODE_COMPONENTS = {
|
export const RUNTIME_NODE_COMPONENTS = {
|
||||||
PlatformIcon,
|
PlatformIcon,
|
||||||
|
BarcodeScanField,
|
||||||
PublicHeaderBar,
|
PublicHeaderBar,
|
||||||
PublicLogo,
|
PublicLogo,
|
||||||
PublicLanguageSelector,
|
PublicLanguageSelector,
|
||||||
|
|
|
||||||
83
ui/src/components/shared/BarcodeScanner/BarcodeScanField.tsx
Normal file
83
ui/src/components/shared/BarcodeScanner/BarcodeScanField.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button, Input } from '@/components/ui'
|
||||||
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
|
import { isCameraAvailable } from '@/utils/device'
|
||||||
|
import BarcodeScannerDialog from './BarcodeScannerDialog'
|
||||||
|
|
||||||
|
export type BarcodeScanFieldProps = {
|
||||||
|
label?: string
|
||||||
|
placeholder?: string
|
||||||
|
buttonText?: string
|
||||||
|
/** Okunan kod formda bu adla taşınır; üretilen kod da aynı adı kullanır. */
|
||||||
|
name?: string
|
||||||
|
/** Açıkken diyalog okumadan sonra kapanmaz, arka arkaya okur. */
|
||||||
|
continuous?: boolean
|
||||||
|
readOnly?: boolean
|
||||||
|
className?: string
|
||||||
|
value?: string
|
||||||
|
onScan?: (code: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tasarımcıdan sürüklenip bırakılabilen barkod alanı. Kanvas ve üretilen kod
|
||||||
|
* aynı bileşeni çizer; okunan değer hem içeride tutulur hem `onScan` ile dışarı
|
||||||
|
* verilir, böylece hem tek başına hem bir formun parçası olarak kullanılabilir.
|
||||||
|
*/
|
||||||
|
const BarcodeScanField = ({
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
buttonText,
|
||||||
|
name,
|
||||||
|
continuous = false,
|
||||||
|
readOnly = true,
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
onScan,
|
||||||
|
}: BarcodeScanFieldProps) => {
|
||||||
|
const { translate } = useLocalization()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [scanned, setScanned] = useState('')
|
||||||
|
const current = value ?? scanned
|
||||||
|
|
||||||
|
const handleDetect = (code: string) => {
|
||||||
|
setScanned(code)
|
||||||
|
onScan?.(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && <label className="mb-1 block text-sm font-semibold">{label}</label>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
name={name}
|
||||||
|
value={current}
|
||||||
|
placeholder={placeholder ?? translate('::App.BarcodeScanner.Placeholder')}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onChange={(event) => {
|
||||||
|
setScanned(event.target.value)
|
||||||
|
onScan?.(event.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="solid"
|
||||||
|
disabled={!isCameraAvailable()}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
{buttonText ?? translate('::App.BarcodeScanner.Scan')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<BarcodeScannerDialog
|
||||||
|
open
|
||||||
|
continuous={continuous}
|
||||||
|
onDetect={handleDetect}
|
||||||
|
onDialogClose={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BarcodeScanField
|
||||||
111
ui/src/components/shared/BarcodeScanner/BarcodeScannerDialog.tsx
Normal file
111
ui/src/components/shared/BarcodeScanner/BarcodeScannerDialog.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button, Dialog } from '@/components/ui'
|
||||||
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
|
import { useBarcodeReader, type BarcodeReaderStatus } from './useBarcodeReader'
|
||||||
|
|
||||||
|
export type BarcodeScannerDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
onDialogClose: () => void
|
||||||
|
/**
|
||||||
|
* Okunan kod. `false` dönerse kod reddedilmiş sayılır ve listeye yazılmaz;
|
||||||
|
* sürekli modda yazma hatasını kullanıcıya göstermek için kullanılır.
|
||||||
|
*/
|
||||||
|
onDetect: (code: string) => boolean | void | Promise<boolean | void>
|
||||||
|
/** Açıkken diyalog okumadan sonra kapanmaz, okunanları listeler. */
|
||||||
|
continuous?: boolean
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMessageKeys: Record<BarcodeReaderStatus, string> = {
|
||||||
|
starting: '::App.BarcodeScanner.Starting',
|
||||||
|
scanning: '::App.BarcodeScanner.Aim',
|
||||||
|
denied: '::App.BarcodeScanner.PermissionDenied',
|
||||||
|
insecure: '::App.BarcodeScanner.InsecureContext',
|
||||||
|
standaloneIos: '::App.BarcodeScanner.StandaloneIos',
|
||||||
|
error: '::App.BarcodeScanner.CameraError',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kamerayla barkod/QR okuma diyaloğu. Tek okuma ve sürekli tarama aynı
|
||||||
|
* bileşendir; fark yalnızca okumadan sonra kapanıp kapanmadığıdır.
|
||||||
|
*/
|
||||||
|
const BarcodeScannerDialog = ({
|
||||||
|
open,
|
||||||
|
onDialogClose,
|
||||||
|
onDetect,
|
||||||
|
continuous = false,
|
||||||
|
title,
|
||||||
|
}: BarcodeScannerDialogProps) => {
|
||||||
|
const { translate } = useLocalization()
|
||||||
|
const [scanned, setScanned] = useState<string[]>([])
|
||||||
|
const [failed, setFailed] = useState<string>()
|
||||||
|
|
||||||
|
const handleDetect = async (code: string) => {
|
||||||
|
const accepted = await onDetect(code)
|
||||||
|
if (accepted === false) {
|
||||||
|
setFailed(code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!continuous) {
|
||||||
|
onDialogClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFailed(undefined)
|
||||||
|
setScanned((previous) => [code, ...previous])
|
||||||
|
}
|
||||||
|
|
||||||
|
const { videoRef, status } = useBarcodeReader({ active: open, onDetect: handleDetect })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog isOpen={open} width={520} onClose={onDialogClose} onRequestClose={onDialogClose}>
|
||||||
|
<h5 className="mb-4">{title ?? translate('::App.BarcodeScanner.Title')}</h5>
|
||||||
|
|
||||||
|
<div className="relative overflow-hidden rounded-lg bg-black">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
className="h-64 w-full object-cover sm:h-80"
|
||||||
|
/>
|
||||||
|
{status === 'scanning' && (
|
||||||
|
// Nişangah: kullanıcıya kodu nereye tutacağını gösterir.
|
||||||
|
<div className="pointer-events-none absolute inset-x-10 inset-y-16 rounded-lg border-2 border-white/80" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-3 text-sm opacity-80">{translate(statusMessageKeys[status])}</p>
|
||||||
|
|
||||||
|
{failed && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">
|
||||||
|
{translate('::App.BarcodeScanner.SaveFailed')} ({failed})
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{continuous && scanned.length > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="mb-1 text-sm font-semibold">
|
||||||
|
{translate('::App.BarcodeScanner.ScannedCount')}: {scanned.length}
|
||||||
|
</p>
|
||||||
|
<ul className="max-h-32 overflow-auto rounded border text-sm">
|
||||||
|
{scanned.map((code, index) => (
|
||||||
|
<li key={`${code}-${index}`} className="border-b px-2 py-1 last:border-b-0">
|
||||||
|
{code}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-5 text-right">
|
||||||
|
<Button variant="solid" onClick={onDialogClose}>
|
||||||
|
{translate(continuous ? '::App.Platform.Close' : '::App.Platform.Cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BarcodeScannerDialog
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import BarcodeScannerDialog from './BarcodeScannerDialog'
|
||||||
|
import { registerBarcodeScanner } from './scanBarcode'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `scanBarcode()` çağrılarını karşılayan tekil diyalog. Uygulama kökünde bir
|
||||||
|
* kez mount edilir; React ağacı dışından açılabilmesinin tek yolu budur.
|
||||||
|
*/
|
||||||
|
const BarcodeScannerHost = () => {
|
||||||
|
const [resolve, setResolve] = useState<((code: string | null) => void) | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerBarcodeScanner((next) => setResolve(() => next))
|
||||||
|
return () => registerBarcodeScanner(undefined)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const finish = (code: string | null) => {
|
||||||
|
resolve?.(code)
|
||||||
|
setResolve(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolve) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BarcodeScannerDialog
|
||||||
|
open
|
||||||
|
onDetect={(code) => finish(code)}
|
||||||
|
onDialogClose={() => finish(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BarcodeScannerHost
|
||||||
3
ui/src/components/shared/BarcodeScanner/index.ts
Normal file
3
ui/src/components/shared/BarcodeScanner/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as BarcodeScanField } from './BarcodeScanField'
|
||||||
|
export { default as BarcodeScannerDialog } from './BarcodeScannerDialog'
|
||||||
|
export { default as BarcodeScannerHost } from './BarcodeScannerHost'
|
||||||
22
ui/src/components/shared/BarcodeScanner/scanBarcode.ts
Normal file
22
ui/src/components/shared/BarcodeScanner/scanBarcode.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
type ScanRequest = (resolve: (code: string | null) => void) => void
|
||||||
|
|
||||||
|
let requestScan: ScanRequest | undefined
|
||||||
|
|
||||||
|
/** Diyaloğu tutan host bileşen kendini burada kaydeder. */
|
||||||
|
export const registerBarcodeScanner = (request?: ScanRequest) => {
|
||||||
|
requestScan = request
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React ağacı dışından (DevExtreme editör butonu, editorScript) barkod okutur.
|
||||||
|
* Vazgeçilirse `null` döner. Host mount edilmemişse sessizce `null` döner ki
|
||||||
|
* çağıran taraf hata yönetmek zorunda kalmasın.
|
||||||
|
*/
|
||||||
|
export const scanBarcode = () =>
|
||||||
|
new Promise<string | null>((resolve) => {
|
||||||
|
if (!requestScan) {
|
||||||
|
resolve(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestScan(resolve)
|
||||||
|
})
|
||||||
123
ui/src/components/shared/BarcodeScanner/useBarcodeReader.ts
Normal file
123
ui/src/components/shared/BarcodeScanner/useBarcodeReader.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
createBarcodeDecoder,
|
||||||
|
openCameraStream,
|
||||||
|
stopCameraStream,
|
||||||
|
vibrateOnScan,
|
||||||
|
} from '@/utils/barcode'
|
||||||
|
import { isCameraAvailable, isCameraBlockedByStandaloneIos } from '@/utils/device'
|
||||||
|
|
||||||
|
export type BarcodeReaderStatus =
|
||||||
|
| 'starting'
|
||||||
|
| 'scanning'
|
||||||
|
| 'denied'
|
||||||
|
| 'insecure'
|
||||||
|
| 'standaloneIos'
|
||||||
|
| 'error'
|
||||||
|
|
||||||
|
/** Kare çözme aralığı. Her karede çözmek mobilde pili hızla tüketir. */
|
||||||
|
const DECODE_INTERVAL_MS = 180
|
||||||
|
|
||||||
|
/** Aynı kodun arka arkaya birden çok kez okunmasını engelleyen pencere. */
|
||||||
|
const DUPLICATE_WINDOW_MS = 1500
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kamerayı açar, kareleri çözer ve okunan kodu bildirir.
|
||||||
|
*
|
||||||
|
* `onDetect` her okumada yeniden bağlanabildiği için ref üzerinden tutulur;
|
||||||
|
* aksi hâlde her render kamerayı kapatıp yeniden açardı.
|
||||||
|
*/
|
||||||
|
export const useBarcodeReader = ({
|
||||||
|
active,
|
||||||
|
onDetect,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
onDetect: (code: string) => void
|
||||||
|
}) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const onDetectRef = useRef(onDetect)
|
||||||
|
const [status, setStatus] = useState<BarcodeReaderStatus>('starting')
|
||||||
|
|
||||||
|
onDetectRef.current = onDetect
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
|
if (isCameraBlockedByStandaloneIos()) {
|
||||||
|
setStatus('standaloneIos')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCameraAvailable()) {
|
||||||
|
setStatus('insecure')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = videoRef.current
|
||||||
|
let stream: MediaStream | undefined
|
||||||
|
let timer: number | undefined
|
||||||
|
let cancelled = false
|
||||||
|
let lastCode = ''
|
||||||
|
let lastCodeAt = 0
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
setStatus('starting')
|
||||||
|
try {
|
||||||
|
const [cameraStream, decode] = await Promise.all([
|
||||||
|
openCameraStream(),
|
||||||
|
createBarcodeDecoder(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
stopCameraStream(cameraStream)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!video) return
|
||||||
|
stream = cameraStream
|
||||||
|
|
||||||
|
video.srcObject = cameraStream
|
||||||
|
await video.play()
|
||||||
|
setStatus('scanning')
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const code = await decode(video)
|
||||||
|
const now = Date.now()
|
||||||
|
if (code && (code !== lastCode || now - lastCodeAt > DUPLICATE_WINDOW_MS)) {
|
||||||
|
lastCode = code
|
||||||
|
lastCodeAt = now
|
||||||
|
vibrateOnScan()
|
||||||
|
onDetectRef.current(code)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Tek bir karenin çözülememesi tarama döngüsünü durdurmaz.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
timer = window.setTimeout(tick, DECODE_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick()
|
||||||
|
} catch (error) {
|
||||||
|
if (cancelled) return
|
||||||
|
const name = (error as DOMException)?.name
|
||||||
|
setStatus(name === 'NotAllowedError' || name === 'SecurityError' ? 'denied' : 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (timer) window.clearTimeout(timer)
|
||||||
|
stopCameraStream(stream)
|
||||||
|
if (video) video.srcObject = null
|
||||||
|
}
|
||||||
|
}, [active])
|
||||||
|
|
||||||
|
return { videoRef, status }
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ export { default as ActionLink } from './ActionLink'
|
||||||
export { default as AdaptableCard } from './AdaptableCard'
|
export { default as AdaptableCard } from './AdaptableCard'
|
||||||
export { default as Affix } from './Affix'
|
export { default as Affix } from './Affix'
|
||||||
export { default as AuthorityCheck } from './AuthorityCheck'
|
export { default as AuthorityCheck } from './AuthorityCheck'
|
||||||
|
export { BarcodeScanField, BarcodeScannerDialog, BarcodeScannerHost } from './BarcodeScanner'
|
||||||
export { default as PermissionCheck } from './PermissionCheck'
|
export { default as PermissionCheck } from './PermissionCheck'
|
||||||
export { default as ConfirmDialog } from './ConfirmDialog'
|
export { default as ConfirmDialog } from './ConfirmDialog'
|
||||||
export { default as Container } from './Container'
|
export { default as Container } from './Container'
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ const UI_PROPERTY_ALIASES: Record<string, Record<string, string>> = {
|
||||||
}
|
}
|
||||||
export const DESIGNER_PRIMARY_EVENTS: Record<string, string[]> = {
|
export const DESIGNER_PRIMARY_EVENTS: Record<string, string[]> = {
|
||||||
AutoComplete: ['onInputChange', 'onSelect'],
|
AutoComplete: ['onInputChange', 'onSelect'],
|
||||||
|
BarcodeScanField: ['onScan'],
|
||||||
Button: ['onClick'],
|
Button: ['onClick'],
|
||||||
Checkbox: ['onChange'],
|
Checkbox: ['onChange'],
|
||||||
Dropdown: ['onSelect'],
|
Dropdown: ['onSelect'],
|
||||||
|
|
@ -129,6 +130,11 @@ const DESIGNER_EVENT_SNIPPETS: Record<string, Record<string, string>> = {
|
||||||
// refs.input1.setEnabled(false)
|
// refs.input1.setEnabled(false)
|
||||||
// refs.card1.setVisible(false)
|
// refs.card1.setVisible(false)
|
||||||
console.log('Button clicked', event)`,
|
console.log('Button clicked', event)`,
|
||||||
|
},
|
||||||
|
BarcodeScanField: {
|
||||||
|
onScan: `// Okunan barkod/QR değeridir (metin).
|
||||||
|
const code = event
|
||||||
|
refs.input1.setValue(code)`,
|
||||||
},
|
},
|
||||||
Checkbox: {
|
Checkbox: {
|
||||||
onChange: `// Checkbox bileşeninin yeni checked değeri.
|
onChange: `// Checkbox bileşeninin yeni checked değeri.
|
||||||
|
|
@ -1217,6 +1223,35 @@ export const DESIGNER_EXTRAS: DesignerComponentDefinition[] = [
|
||||||
],
|
],
|
||||||
hooks: [],
|
hooks: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Kamerayla barkod/QR okuyan alan. Kanvas ve uretilen kod ayni bileseni
|
||||||
|
// cizer (`components/shared/BarcodeScanner`).
|
||||||
|
name: 'BarcodeScanField',
|
||||||
|
icon: 'ScanLine',
|
||||||
|
category: 'basic',
|
||||||
|
kind: 'custom',
|
||||||
|
toolboxGroup: 'ui',
|
||||||
|
description: 'App.CatalogBarcodeScanField.BarcodeScanFieldDescription',
|
||||||
|
properties: [
|
||||||
|
{ name: 'label', type: 'string', value: '', category: 'properties' },
|
||||||
|
{ name: 'name', type: 'string', value: 'barcode', category: 'properties' },
|
||||||
|
{ name: 'placeholder', type: 'string', value: '', category: 'properties' },
|
||||||
|
{ name: 'buttonText', type: 'string', value: '', category: 'properties' },
|
||||||
|
{ name: 'continuous', type: 'boolean', value: false, category: 'properties' },
|
||||||
|
{ name: 'readOnly', type: 'boolean', value: true, category: 'properties' },
|
||||||
|
{ name: 'className', type: 'string', value: '', category: 'styling' },
|
||||||
|
// `category: 'events'` alanı Özellikler panelinden çıkarıp JavaScript
|
||||||
|
// sekmesine taşır; orada değer değil script olarak düzenlenir.
|
||||||
|
{
|
||||||
|
name: 'onScan',
|
||||||
|
type: 'function',
|
||||||
|
value: '',
|
||||||
|
category: 'events',
|
||||||
|
description: 'App.CatalogBarcodeScanField.OnScanDescription',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hooks: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Spacer',
|
name: 'Spacer',
|
||||||
icon: 'Space',
|
icon: 'Space',
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,8 @@ export interface ImageUploadOptionsDto {
|
||||||
maxFileSize?: number
|
maxFileSize?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
|
/** Mobilde dosya seçici yerine kamerayı açar. */
|
||||||
|
capture?: 'environment' | 'user'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RowDto {
|
export interface RowDto {
|
||||||
|
|
|
||||||
119
ui/src/utils/barcode.ts
Normal file
119
ui/src/utils/barcode.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
/**
|
||||||
|
* Barkod okuma altyapısı.
|
||||||
|
*
|
||||||
|
* Tarayıcıda iki çözücü yolu var: Chromium tabanlı tarayıcılar `BarcodeDetector`
|
||||||
|
* ile yerleşik (WASM indirmeden, donanım hızlandırmalı) okur; iOS Safari ve
|
||||||
|
* Firefox'ta bu API yok, o yüzden `zxing-wasm` yalnızca gerektiğinde lazy
|
||||||
|
* yüklenir. Böylece Android cihazlarda ek bir byte inmez.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `BarcodeDetector` format adları (snake_case). 1D + QR. */
|
||||||
|
const DETECTOR_FORMATS = [
|
||||||
|
'qr_code',
|
||||||
|
'code_128',
|
||||||
|
'code_39',
|
||||||
|
'code_93',
|
||||||
|
'ean_13',
|
||||||
|
'ean_8',
|
||||||
|
'upc_a',
|
||||||
|
'upc_e',
|
||||||
|
'itf',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** Aynı küme zxing adlandırmasıyla. İki liste birlikte değişir. */
|
||||||
|
const ZXING_FORMATS = [
|
||||||
|
'QRCode',
|
||||||
|
'Code128',
|
||||||
|
'Code39',
|
||||||
|
'Code93',
|
||||||
|
'EAN13',
|
||||||
|
'EAN8',
|
||||||
|
'UPCA',
|
||||||
|
'UPCE',
|
||||||
|
'ITF',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type BarcodeDetectorLike = {
|
||||||
|
detect: (source: CanvasImageSource) => Promise<{ rawValue: string }[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
type DetectorWindow = Window & {
|
||||||
|
BarcodeDetector?: new (options?: { formats?: readonly string[] }) => BarcodeDetectorLike
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BarcodeDecoder = (frame: HTMLVideoElement) => Promise<string | undefined>
|
||||||
|
|
||||||
|
type ZxingReader = (imageData: ImageData) => Promise<string | undefined>
|
||||||
|
|
||||||
|
let zxingReader: Promise<ZxingReader> | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* zxing-wasm'ı bir kez yükler. WASM dosyası paketten `?url` ile alınır; paketin
|
||||||
|
* varsayılanı jsDelivr CDN'idir ve çevrimdışı/kapalı ağ kurulumlarında çalışmaz.
|
||||||
|
*/
|
||||||
|
const getZxingReader = () => {
|
||||||
|
zxingReader ??= (async () => {
|
||||||
|
const [{ prepareZXingModule, readBarcodesFromImageData }, { default: wasmUrl }] =
|
||||||
|
await Promise.all([
|
||||||
|
import('zxing-wasm/reader'),
|
||||||
|
import('zxing-wasm/reader/zxing_reader.wasm?url'),
|
||||||
|
])
|
||||||
|
|
||||||
|
prepareZXingModule({ overrides: { locateFile: () => wasmUrl } })
|
||||||
|
|
||||||
|
return async (imageData: ImageData) => {
|
||||||
|
const results = await readBarcodesFromImageData(imageData, {
|
||||||
|
formats: [...ZXING_FORMATS],
|
||||||
|
tryHarder: false,
|
||||||
|
})
|
||||||
|
return results.find((result) => result.text)?.text
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return zxingReader
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ortama göre çözücü seçer. Çağıran taraf hangi yolun kullanıldığını bilmez;
|
||||||
|
* her iki yol da video karesini alıp okunan metni döner.
|
||||||
|
*/
|
||||||
|
export const createBarcodeDecoder = async (): Promise<BarcodeDecoder> => {
|
||||||
|
const DetectorCtor = (window as DetectorWindow).BarcodeDetector
|
||||||
|
|
||||||
|
if (DetectorCtor) {
|
||||||
|
const detector = new DetectorCtor({ formats: DETECTOR_FORMATS })
|
||||||
|
return async (frame) => {
|
||||||
|
const codes = await detector.detect(frame)
|
||||||
|
return codes.find((code) => code.rawValue)?.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = await getZxingReader()
|
||||||
|
// WASM yolunda kare canvas'a indirgenir; canvas tek kez oluşturulup yeniden kullanılır.
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const context = canvas.getContext('2d', { willReadFrequently: true })
|
||||||
|
|
||||||
|
return async (frame) => {
|
||||||
|
if (!context || !frame.videoWidth || !frame.videoHeight) return undefined
|
||||||
|
canvas.width = frame.videoWidth
|
||||||
|
canvas.height = frame.videoHeight
|
||||||
|
context.drawImage(frame, 0, 0)
|
||||||
|
return read(context.getImageData(0, 0, canvas.width, canvas.height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arka kamerayı tercih eden akış. İzin reddedilirse hata çağırana bırakılır. */
|
||||||
|
export const openCameraStream = () =>
|
||||||
|
navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: { ideal: 'environment' },
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 },
|
||||||
|
},
|
||||||
|
audio: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const stopCameraStream = (stream?: MediaStream | null) =>
|
||||||
|
stream?.getTracks().forEach((track) => track.stop())
|
||||||
|
|
||||||
|
/** Okuma geri bildirimi. Desteklemeyen cihazda sessizce atlanır. */
|
||||||
|
export const vibrateOnScan = () => navigator.vibrate?.(60)
|
||||||
35
ui/src/utils/device.ts
Normal file
35
ui/src/utils/device.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
/**
|
||||||
|
* Cihaz yetenekleri. Kamera/konum gibi özellikler tarayıcıya ve bağlama göre
|
||||||
|
* değiştiği için tek yerden sorulur; ekranlar `navigator` kontrolü yapmaz.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Kamera güvenli bağlam ister; HTTP üzerinden getUserMedia hiç tanımlı değildir. */
|
||||||
|
export const isCameraAvailable = () =>
|
||||||
|
typeof window !== 'undefined' && window.isSecureContext && !!navigator.mediaDevices?.getUserMedia
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS'ta ana ekrana eklenmiş PWA içinde getUserMedia yalnızca 16.4+ sürümlerde
|
||||||
|
* çalışır; altındaki sürümlerde kamera sessizce boş akış döner. Kullanıcıya
|
||||||
|
* "Safari'de aç" diyebilmek için ayrıca tespit ediyoruz.
|
||||||
|
*/
|
||||||
|
export const isCameraBlockedByStandaloneIos = () => {
|
||||||
|
if (typeof navigator === 'undefined') return false
|
||||||
|
if (!/iPad|iPhone|iPod/.test(navigator.userAgent)) return false
|
||||||
|
if ((navigator as Navigator & { standalone?: boolean }).standalone !== true) return false
|
||||||
|
|
||||||
|
const version = /OS (\d+)_(\d+)/.exec(navigator.userAgent)
|
||||||
|
if (!version) return false
|
||||||
|
const [major, minor] = [Number(version[1]), Number(version[2])]
|
||||||
|
return major < 16 || (major === 16 && minor < 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<input capture>` yalnızca dokunmatik cihazlarda kamerayı açar; masaüstünde
|
||||||
|
* sessizce yok sayılır ve aynı dosya seçicisini ikinci kez açan bir buton
|
||||||
|
* bırakırdı. Bu yüzden kamera butonu yalnızca kaba işaretçili (parmak) ve
|
||||||
|
* arka kamerası olması beklenen cihazlarda gösterilir.
|
||||||
|
*/
|
||||||
|
export const supportsPhotoCapture = () =>
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof window.matchMedia === 'function' &&
|
||||||
|
window.matchMedia('(pointer: coarse)').matches
|
||||||
|
|
@ -13,6 +13,9 @@
|
||||||
* yeniden derlenir.
|
* yeniden derlenir.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { getLocalization } from '@/services/localization.service'
|
||||||
|
import { store } from '@/store'
|
||||||
|
|
||||||
export type EditorScriptRuntimeContext = {
|
export type EditorScriptRuntimeContext = {
|
||||||
formData: Record<string, any>
|
formData: Record<string, any>
|
||||||
e: any
|
e: any
|
||||||
|
|
@ -56,6 +59,7 @@ const HELPER_PARAMS = [
|
||||||
'ask',
|
'ask',
|
||||||
'openUrl',
|
'openUrl',
|
||||||
'api',
|
'api',
|
||||||
|
'geoTo',
|
||||||
'revert',
|
'revert',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -63,6 +67,12 @@ const HELPER_PARAMS = [
|
||||||
* Ortak yardımcılar
|
* Ortak yardımcılar
|
||||||
* ------------------------------------------------------------------ */
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Script runtime React ağacının dışındadır; dil metni store'dan okunur. */
|
||||||
|
const translateRuntime = (key: string) => {
|
||||||
|
const { texts, config } = store.getState().abpConfig
|
||||||
|
return getLocalization(texts ?? {}, config?.localization?.defaultResourceName, key)
|
||||||
|
}
|
||||||
|
|
||||||
/** Alan adını büyük/küçük harf duyarsız arar; SQL kolonları tutarsız gelebiliyor. */
|
/** Alan adını büyük/küçük harf duyarsız arar; SQL kolonları tutarsız gelebiliyor. */
|
||||||
const resolveKey = (source: any, name: string): string | undefined => {
|
const resolveKey = (source: any, name: string): string | undefined => {
|
||||||
if (!source || typeof source !== 'object') return undefined
|
if (!source || typeof source !== 'object') return undefined
|
||||||
|
|
@ -243,6 +253,36 @@ const buildHelpers = ({
|
||||||
return responsePath ? pickPath(json, responsePath) : json
|
return responsePath ? pickPath(json, responsePath) : json
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cihazın konumunu alanlara yazar. Tek atıştır; izin verilmezse ya da konum
|
||||||
|
* alınamazsa alanlara dokunulmaz ve kullanıcıya mesaj gösterilir — sessizce
|
||||||
|
* boş değer yazmak, kullanıcının konumu kaydettiğini sanmasına yol açardı.
|
||||||
|
*/
|
||||||
|
const geoTo = async (latField: string, lngField?: string, accuracyField?: string) => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
notify(translateRuntime('::App.Geolocation.Unsupported'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||||
|
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 10000,
|
||||||
|
maximumAge: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
set(latField, position.coords.latitude)
|
||||||
|
if (lngField) set(lngField, position.coords.longitude)
|
||||||
|
if (accuracyField) set(accuracyField, Math.round(position.coords.accuracy))
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
notify(translateRuntime('::App.Geolocation.Failed'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const helpers = {
|
const helpers = {
|
||||||
value: e?.value,
|
value: e?.value,
|
||||||
field,
|
field,
|
||||||
|
|
@ -270,6 +310,7 @@ const buildHelpers = ({
|
||||||
ask,
|
ask,
|
||||||
openUrl,
|
openUrl,
|
||||||
api,
|
api,
|
||||||
|
geoTo,
|
||||||
revert,
|
revert,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,6 +345,7 @@ const buildHelpers = ({
|
||||||
helpers.ask,
|
helpers.ask,
|
||||||
helpers.openUrl,
|
helpers.openUrl,
|
||||||
helpers.api,
|
helpers.api,
|
||||||
|
helpers.geoTo,
|
||||||
helpers.revert,
|
helpers.revert,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { LayoutType } from '@/proxy/theme/models'
|
import type { LayoutType } from '@/proxy/theme/models'
|
||||||
|
import { BarcodeScannerHost } from '@/components/shared'
|
||||||
import Loading from '@/components/shared/Loading'
|
import Loading from '@/components/shared/Loading'
|
||||||
import { Alert, Button } from '@/components/ui'
|
import { Alert, Button } from '@/components/ui'
|
||||||
import { store, useStoreState } from '@/store'
|
import { store, useStoreState } from '@/store'
|
||||||
|
|
@ -89,6 +90,7 @@ const Views = (props: ViewsProps) => {
|
||||||
<small className="italic">{e.cid}</small>
|
<small className="italic">{e.cid}</small>
|
||||||
</UiDialog>
|
</UiDialog>
|
||||||
))}
|
))}
|
||||||
|
<BarcodeScannerHost />
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<DialogShowComponent />
|
<DialogShowComponent />
|
||||||
<DynamicRouter />
|
<DynamicRouter />
|
||||||
|
|
|
||||||
|
|
@ -782,6 +782,19 @@ export const optionSpecs: OptionSpec[] = [
|
||||||
platform: true,
|
platform: true,
|
||||||
placeholder: 'image/*',
|
placeholder: 'image/*',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'capture',
|
||||||
|
label: 'capture',
|
||||||
|
type: 'select',
|
||||||
|
group: 'image',
|
||||||
|
editors: IMAGE_EDITORS,
|
||||||
|
platform: true,
|
||||||
|
help: 'App.SpecCapture.Help',
|
||||||
|
choices: [
|
||||||
|
{ value: 'environment', label: 'App.SpecCapture.Environment' },
|
||||||
|
{ value: 'user', label: 'App.SpecCapture.User' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'multiple',
|
path: 'multiple',
|
||||||
label: 'multiple',
|
label: 'multiple',
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,29 @@ export const editorOptionsPresets: EditorOptionsPreset[] = [
|
||||||
editors: ['dxHtmlEditor'],
|
editors: ['dxHtmlEditor'],
|
||||||
value: buildHtmlEditorOptions(),
|
value: buildHtmlEditorOptions(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'photoCapture',
|
||||||
|
label: 'App.PresetPhotoCapture.PhotoCaptureLabel',
|
||||||
|
description: 'App.PresetPhotoCapture.PhotoCaptureDescription',
|
||||||
|
editors: ['dxImageUpload'],
|
||||||
|
value: { accept: 'image/*', capture: 'environment' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'barcodeScan',
|
||||||
|
label: 'App.PresetBarcodeScan.BarcodeScanLabel',
|
||||||
|
description: 'App.PresetBarcodeScan.BarcodeScanDescription',
|
||||||
|
editors: ['dxTextBox'],
|
||||||
|
value: {
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
name: 'scanBarcode',
|
||||||
|
location: 'after',
|
||||||
|
// '@SCAN_BARCODE' token'ini editingForm.ts kamera diyaloguna baglar.
|
||||||
|
options: { icon: 'photo', stylingMode: 'text', onClick: '@SCAN_BARCODE' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'height100',
|
key: 'height100',
|
||||||
label: 'App.PresetHeight100.Height100Label',
|
label: 'App.PresetHeight100.Height100Label',
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ export type RecipeId =
|
||||||
| 'ask'
|
| 'ask'
|
||||||
| 'openUrl'
|
| 'openUrl'
|
||||||
| 'apiToField'
|
| 'apiToField'
|
||||||
|
| 'location'
|
||||||
| 'custom'
|
| 'custom'
|
||||||
|
|
||||||
export const recipeGroups: { key: RecipeGroupKey; title: string; description: string }[] = [
|
export const recipeGroups: { key: RecipeGroupKey; title: string; description: string }[] = [
|
||||||
|
|
@ -667,6 +668,33 @@ export const recipes: Recipe[] = [
|
||||||
target: label(params.target, t('::App.Listform.ListformField.Target')),
|
target: label(params.target, t('::App.Listform.ListformField.Target')),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'location',
|
||||||
|
label: 'App.ScriptBuilderLocation.LocationLabel',
|
||||||
|
group: 'integration',
|
||||||
|
summary: 'App.ScriptBuilderLocation.LocationSummary',
|
||||||
|
example: "await geoTo('Enlem', 'Boylam')",
|
||||||
|
params: [
|
||||||
|
{ key: 'lat', label: 'App.ScriptBuilderLocation.LatLabel', type: 'field' },
|
||||||
|
{ key: 'lng', label: 'App.ScriptBuilderLocation.LngLabel', type: 'field' },
|
||||||
|
{
|
||||||
|
key: 'accuracy',
|
||||||
|
label: 'App.ScriptBuilderLocation.AccuracyLabel',
|
||||||
|
type: 'field',
|
||||||
|
optional: true,
|
||||||
|
help: 'App.ScriptBuilderLocation.AccuracyHelp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
build: ({ params }) =>
|
||||||
|
`await geoTo(${q(params.lat)}, ${q(params.lng)}${
|
||||||
|
params.accuracy ? `, ${q(params.accuracy)}` : ''
|
||||||
|
})`,
|
||||||
|
describe: ({ params }, t) =>
|
||||||
|
t('::App.ScriptBuilderDescribe.Location', {
|
||||||
|
lat: label(params.lat, t('::App.ScriptBuilderLocation.LatLabel')),
|
||||||
|
lng: label(params.lng, t('::App.ScriptBuilderLocation.LngLabel')),
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'custom',
|
id: 'custom',
|
||||||
label: 'App.ScriptBuilderCustom.CustomLabel',
|
label: 'App.ScriptBuilderCustom.CustomLabel',
|
||||||
|
|
|
||||||
|
|
@ -5732,6 +5732,13 @@ const VisualComponentDesigner = () => {
|
||||||
<FaBolt className="shrink-0 text-[10px]" />
|
<FaBolt className="shrink-0 text-[10px]" />
|
||||||
<span className="min-w-0 flex-1 truncate text-xs font-semibold">
|
<span className="min-w-0 flex-1 truncate text-xs font-semibold">
|
||||||
{target.node.type}
|
{target.node.type}
|
||||||
|
{/* Aynı tipten birden çok bileşen listede ayırt edilemiyordu;
|
||||||
|
ref adı script içinde kullanılan addır. */}
|
||||||
|
{target.node.ref && (
|
||||||
|
<span className="ml-1 font-normal text-sky-600 dark:text-sky-400">
|
||||||
|
({target.node.ref})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded bg-slate-200 px-1.5 py-0.5 text-[9px] dark:bg-gray-800">
|
<span className="rounded bg-slate-200 px-1.5 py-0.5 text-[9px] dark:bg-gray-800">
|
||||||
{target.eventNames.length}
|
{target.eventNames.length}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { ImageUploadOptionsDto } from '@/proxy/form/models'
|
import { ImageUploadOptionsDto } from '@/proxy/form/models'
|
||||||
import { ReactElement, useEffect, useMemo, useRef, useState } from 'react'
|
import { ReactElement, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { FaSpinner, FaUpload } from 'react-icons/fa'
|
import { FaCamera, FaSpinner, FaUpload } from 'react-icons/fa'
|
||||||
import { Button } from '@/components/ui'
|
import { Button } from '@/components/ui'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
import {
|
import {
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
} from './imageHoverPreview'
|
} from './imageHoverPreview'
|
||||||
import { normalizeImageSize } from '../../list/shared/imageValue'
|
import { normalizeImageSize } from '../../list/shared/imageValue'
|
||||||
import Input from '@/components/ui/Input'
|
import Input from '@/components/ui/Input'
|
||||||
|
import { supportsPhotoCapture } from '@/utils/device'
|
||||||
|
|
||||||
const ImageUploadEditorComponent = ({
|
const ImageUploadEditorComponent = ({
|
||||||
value,
|
value,
|
||||||
|
|
@ -34,6 +35,9 @@ const ImageUploadEditorComponent = ({
|
||||||
|
|
||||||
const thumbW = normalizeImageSize(resolvedOptions.width, 40)
|
const thumbW = normalizeImageSize(resolvedOptions.width, 40)
|
||||||
const thumbH = normalizeImageSize(resolvedOptions.height, 40)
|
const thumbH = normalizeImageSize(resolvedOptions.height, 40)
|
||||||
|
// Alanda `capture` acikca ayarliysa kullanicinin istedigi tek davranis odur;
|
||||||
|
// aksi halde dokunmatik cihazda dosya seciciye ek olarak kamera sunulur.
|
||||||
|
const showCameraButton = !resolvedOptions.capture && supportsPhotoCapture()
|
||||||
|
|
||||||
const valueUrls = useMemo<string[]>(
|
const valueUrls = useMemo<string[]>(
|
||||||
() => (Array.isArray(value) ? value : [value]).filter(Boolean) as string[],
|
() => (Array.isArray(value) ? value : [value]).filter(Boolean) as string[],
|
||||||
|
|
@ -43,6 +47,7 @@ const ImageUploadEditorComponent = ({
|
||||||
const [urls, setUrls] = useState<string[]>(valueUrls)
|
const [urls, setUrls] = useState<string[]>(valueUrls)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const cameraInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Değer dışarıdan (editör script'i, veri yenileme…) değiştiğinde önizlemeler
|
// Değer dışarıdan (editör script'i, veri yenileme…) değiştiğinde önizlemeler
|
||||||
// güncellenmiyordu; state yalnızca ilk render'da kuruluyordu.
|
// güncellenmiyordu; state yalnızca ilk render'da kuruluyordu.
|
||||||
|
|
@ -98,6 +103,8 @@ const ImageUploadEditorComponent = ({
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
if (inputRef.current) inputRef.current.value = ''
|
if (inputRef.current) inputRef.current.value = ''
|
||||||
|
// Ayni fotografi ikinci kez cekmek degisiklik olayini tetiklemezdi.
|
||||||
|
if (cameraInputRef.current) cameraInputRef.current.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,13 +166,27 @@ const ImageUploadEditorComponent = ({
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept={resolvedOptions.accept ?? 'image/*'}
|
accept={resolvedOptions.accept ?? 'image/*'}
|
||||||
|
capture={resolvedOptions.capture}
|
||||||
multiple={isMultiple}
|
multiple={isMultiple}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={(e) => handleFiles(e.target.files)}
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
/>
|
/>
|
||||||
|
{/* Kamera ayri bir input'tur: `capture` dosya seciciyi kapattigi icin
|
||||||
|
tek input ile hem galeri hem kamera sunulamaz. */}
|
||||||
|
{showCameraButton && (
|
||||||
|
<Input
|
||||||
|
ref={cameraInputRef}
|
||||||
|
unstyle
|
||||||
|
type="file"
|
||||||
|
accept={resolvedOptions.accept ?? 'image/*'}
|
||||||
|
capture="environment"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
title="Görsel Yükle"
|
title={translate('::App.ImageUpload.Upload')}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
variant="solid"
|
variant="solid"
|
||||||
|
|
@ -190,6 +211,19 @@ const ImageUploadEditorComponent = ({
|
||||||
<FaUpload style={{ fontSize: 14 }} />
|
<FaUpload style={{ fontSize: 14 }} />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{showCameraButton && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
title={translate('::App.ImageUpload.TakePhoto')}
|
||||||
|
disabled={uploading}
|
||||||
|
variant="solid"
|
||||||
|
shape="none"
|
||||||
|
className="!h-auto !rounded !bg-[#0078d4] !px-2.5 !py-1 !text-[13px] !text-white"
|
||||||
|
onClick={() => cameraInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<FaCamera style={{ fontSize: 14 }} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { FaSpinner, FaUpload } from 'react-icons/fa'
|
import { FaCamera, FaSpinner, FaUpload } from 'react-icons/fa'
|
||||||
import { Button } from '@/components/ui'
|
import { Button } from '@/components/ui'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
import {
|
import {
|
||||||
|
|
@ -10,9 +10,12 @@ import {
|
||||||
} from '../../form/editors/imageHoverPreview'
|
} from '../../form/editors/imageHoverPreview'
|
||||||
import { normalizeImageSize, parseJsonObject } from '../shared/imageValue'
|
import { normalizeImageSize, parseJsonObject } from '../shared/imageValue'
|
||||||
import Input from '@/components/ui/Input'
|
import Input from '@/components/ui/Input'
|
||||||
|
import { supportsPhotoCapture } from '@/utils/device'
|
||||||
|
|
||||||
type ImageUploadOptions = {
|
type ImageUploadOptions = {
|
||||||
accept?: string
|
accept?: string
|
||||||
|
/** Mobilde dosya seçici yerine kamerayı açar. */
|
||||||
|
capture?: 'environment' | 'user'
|
||||||
fileFieldName?: string
|
fileFieldName?: string
|
||||||
height?: number | string
|
height?: number | string
|
||||||
multiple?: boolean | string
|
multiple?: boolean | string
|
||||||
|
|
@ -79,6 +82,7 @@ const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
|
||||||
const col = cellElement.column
|
const col = cellElement.column
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const cameraInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [urls, setUrls] = useState<string[]>(() => normalizeUploadValue(cellElement.value))
|
const [urls, setUrls] = useState<string[]>(() => normalizeUploadValue(cellElement.value))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -96,6 +100,9 @@ const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
|
||||||
|
|
||||||
const thumbW = normalizeImageSize(options.width, 40)
|
const thumbW = normalizeImageSize(options.width, 40)
|
||||||
const thumbH = normalizeImageSize(options.height, 40)
|
const thumbH = normalizeImageSize(options.height, 40)
|
||||||
|
// Alanda `capture` acikca ayarliysa kullanicinin istedigi tek davranis odur;
|
||||||
|
// aksi halde dokunmatik cihazda dosya seciciye ek olarak kamera sunulur.
|
||||||
|
const showCameraButton = !options.capture && supportsPhotoCapture()
|
||||||
|
|
||||||
const removeImage = (index: number) => {
|
const removeImage = (index: number) => {
|
||||||
const newUrls = urls.filter((_, i) => i !== index)
|
const newUrls = urls.filter((_, i) => i !== index)
|
||||||
|
|
@ -141,6 +148,8 @@ const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
if (inputRef.current) inputRef.current.value = ''
|
if (inputRef.current) inputRef.current.value = ''
|
||||||
|
// Ayni fotografi ikinci kez cekmek degisiklik olayini tetiklemezdi.
|
||||||
|
if (cameraInputRef.current) cameraInputRef.current.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,13 +207,27 @@ const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept={options.accept ?? 'image/*'}
|
accept={options.accept ?? 'image/*'}
|
||||||
|
capture={options.capture}
|
||||||
multiple={isMultiple}
|
multiple={isMultiple}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={(e) => handleFiles(e.target.files)}
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
/>
|
/>
|
||||||
|
{/* Kamera ayrı bir input'tur: `capture` dosya seçiciyi kapattığı için
|
||||||
|
tek input ile hem galeri hem kamera sunulamaz. */}
|
||||||
|
{showCameraButton && (
|
||||||
|
<Input
|
||||||
|
ref={cameraInputRef}
|
||||||
|
unstyle
|
||||||
|
type="file"
|
||||||
|
accept={options.accept ?? 'image/*'}
|
||||||
|
capture="environment"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
title="Görsel Yükle"
|
title={translate('::App.ImageUpload.Upload')}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
variant="solid"
|
variant="solid"
|
||||||
shape="none"
|
shape="none"
|
||||||
|
|
@ -229,6 +252,19 @@ const ImageUploadEditorComponent = (cellElement: any): ReactElement => {
|
||||||
<FaUpload style={{ fontSize: 14 }} />
|
<FaUpload style={{ fontSize: 14 }} />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
{showCameraButton && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
title={translate('::App.ImageUpload.TakePhoto')}
|
||||||
|
disabled={uploading}
|
||||||
|
variant="solid"
|
||||||
|
shape="none"
|
||||||
|
className="!h-auto !rounded !bg-[#0078d4] !px-2.5 !py-1 !text-[13px] !text-white"
|
||||||
|
onClick={() => cameraInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<FaCamera style={{ fontSize: 14 }} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
GridDto,
|
GridDto,
|
||||||
PlatformEditorTypes,
|
PlatformEditorTypes,
|
||||||
} from '@/proxy/form/models'
|
} from '@/proxy/form/models'
|
||||||
|
import { scanBarcode } from '@/components/shared/BarcodeScanner/scanBarcode'
|
||||||
import { getNextSequenceValue } from '@/services/form.service'
|
import { getNextSequenceValue } from '@/services/form.service'
|
||||||
import type { EditorType2, RowMode, SimpleItemWithColData } from '../../form/types'
|
import type { EditorType2, RowMode, SimpleItemWithColData } from '../../form/types'
|
||||||
import {
|
import {
|
||||||
|
|
@ -26,6 +27,21 @@ import {
|
||||||
|
|
||||||
export type EditorOptionsWithButtons = { buttons?: any[] } & Record<string, any>
|
export type EditorOptionsWithButtons = { buttons?: any[] } & Record<string, any>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `editorOptions.buttons` içinde bu değeri taşıyan `onClick`, kamerayla barkod
|
||||||
|
* okuyup değeri alana yazar. Token kullanılmasının nedeni, buton tanımının
|
||||||
|
* veritabanında JSON olarak durması ve oradan fonksiyon taşınamamasıdır.
|
||||||
|
*/
|
||||||
|
const SCAN_BARCODE_TOKEN = '@SCAN_BARCODE'
|
||||||
|
|
||||||
|
/** Token'ın arkasındaki gerçek davranış. Okuma iptal edilirse alana dokunulmaz. */
|
||||||
|
const scanBarcodeIntoEditor = async (e: any) => {
|
||||||
|
const code = await scanBarcode()
|
||||||
|
if (code !== null) {
|
||||||
|
e?.component?.option('value', code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ *
|
/* ------------------------------------------------------------------ *
|
||||||
* Satır anahtarı yardımcıları
|
* Satır anahtarı yardımcıları
|
||||||
* ------------------------------------------------------------------ */
|
* ------------------------------------------------------------------ */
|
||||||
|
|
@ -476,7 +492,9 @@ export const createFormItemMapper = ({
|
||||||
|
|
||||||
if (editorOptions.buttons) {
|
if (editorOptions.buttons) {
|
||||||
editorOptions.buttons = editorOptions.buttons.map((btn: any) => {
|
editorOptions.buttons = editorOptions.buttons.map((btn: any) => {
|
||||||
if (typeof btn?.options?.onClick === 'string') {
|
if (btn?.options?.onClick === SCAN_BARCODE_TOKEN) {
|
||||||
|
btn.options.onClick = scanBarcodeIntoEditor
|
||||||
|
} else if (typeof btn?.options?.onClick === 'string') {
|
||||||
// Yapılandırmadan gelen fonksiyon tanımı (platform tasarımı gereği).
|
// Yapılandırmadan gelen fonksiyon tanımı (platform tasarımı gereği).
|
||||||
btn.options.onClick = eval(`(${btn.options.onClick})`)
|
btn.options.onClick = eval(`(${btn.options.onClick})`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ import { workflowService } from '@/services/workflow.service'
|
||||||
import type { WorkflowRunResultDto } from '@/services/workflow.service'
|
import type { WorkflowRunResultDto } from '@/services/workflow.service'
|
||||||
import { splitWorkflowApprovers, translateWorkflowLabel } from '@/utils/workflow/workflowHelpers'
|
import { splitWorkflowApprovers, translateWorkflowLabel } from '@/utils/workflow/workflowHelpers'
|
||||||
import { safeJsonParse } from './Utils'
|
import { safeJsonParse } from './Utils'
|
||||||
|
import { BarcodeScannerDialog } from '@/components/shared'
|
||||||
|
|
||||||
|
/** Toolbar butonunun `dialogName` degeri; surekli barkod taramasini acar. */
|
||||||
|
const BARCODE_SCAN_DIALOG = 'BarcodeScanDialog'
|
||||||
|
|
||||||
type ToolbarModalData = {
|
type ToolbarModalData = {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
|
@ -87,6 +91,56 @@ const useToolbar = ({
|
||||||
const [toolbarModalData, setToolbarModalData] = useState<ToolbarModalData>()
|
const [toolbarModalData, setToolbarModalData] = useState<ToolbarModalData>()
|
||||||
const grdOpt = gridDto?.gridOptions
|
const grdOpt = gridDto?.gridOptions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surekli barkod taramasi. Okunan her kod listeye yeni bir satir olarak
|
||||||
|
* yazilir; ekranin kendi insert ucundan gectigi icin kiracin izolasyonu ve
|
||||||
|
* yetki kontrolu normal satir eklemeyle aynidir.
|
||||||
|
*/
|
||||||
|
const openBarcodeScan = (action: { dialogParameters?: string }) => {
|
||||||
|
const parameters = safeJsonParse<Record<string, string>>(action.dialogParameters, {})
|
||||||
|
const targetField = parameters.field
|
||||||
|
const insertUrl = grdOpt?.insertServiceAddress
|
||||||
|
|
||||||
|
if (!targetField || !insertUrl) {
|
||||||
|
toast.push(
|
||||||
|
<Notification type="warning" duration={4000}>
|
||||||
|
{translate('::App.BarcodeScanner.NotConfigured')}
|
||||||
|
</Notification>,
|
||||||
|
{ placement: 'bottom-end' },
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Barkod disindaki sabit alanlar (durum, depo vb.) dialogParameters'tan gelir.
|
||||||
|
const { field: _field, ...defaults } = parameters
|
||||||
|
|
||||||
|
setToolbarModalData({
|
||||||
|
open: true,
|
||||||
|
content: (
|
||||||
|
<BarcodeScannerDialog
|
||||||
|
open
|
||||||
|
continuous
|
||||||
|
onDetect={async (code) => {
|
||||||
|
try {
|
||||||
|
await dynamicFetch(insertUrl, 'POST', undefined, {
|
||||||
|
data: { ...defaults, [targetField]: code },
|
||||||
|
listFormCode,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
// Diyalog acik kalir; kullanici kodu tekrar okutabilir.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDialogClose={() => {
|
||||||
|
setToolbarModalData(undefined)
|
||||||
|
refreshData()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function getToolbarData() {
|
function getToolbarData() {
|
||||||
const items: ToolbarItem[] = []
|
const items: ToolbarItem[] = []
|
||||||
|
|
||||||
|
|
@ -673,7 +727,9 @@ const useToolbar = ({
|
||||||
if (typeof e.event?.preventDefault === 'function') {
|
if (typeof e.event?.preventDefault === 'function') {
|
||||||
e?.event?.preventDefault()
|
e?.event?.preventDefault()
|
||||||
}
|
}
|
||||||
if (action.url) {
|
if (action.dialogName === BARCODE_SCAN_DIALOG) {
|
||||||
|
openBarcodeScan(action)
|
||||||
|
} else if (action.url) {
|
||||||
let url = action.url
|
let url = action.url
|
||||||
// griddeki secili satirlari al
|
// griddeki secili satirlari al
|
||||||
const selectedRowsData = getSelectedRowsData()
|
const selectedRowsData = getSelectedRowsData()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue