tourfp 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -12
- package/package.json +2 -2
- package/tourfp.js +117 -17
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# tourfp
|
|
2
2
|
|
|
3
3
|
Bản thử nghiệm thư viện ghi và phát tour hướng dẫn trên giao diện web, không cần sửa mã nguồn của app.
|
|
4
4
|
Phần tử được nhận diện bằng "dấu vân tay" nhiều tín hiệu (chữ, icon, thuộc tính, component cha, tiêu đề khối đứng trước)
|
|
@@ -8,12 +8,10 @@ Toàn bộ nằm trong một file: [`tourfp.js`](tourfp.js), không phụ thuộ
|
|
|
8
8
|
|
|
9
9
|
## Cài đặt
|
|
10
10
|
|
|
11
|
-
Thư viện là
|
|
12
|
-
|
|
13
|
-
Cài thẳng từ repo này (repo đang riêng tư, nên máy cài phải có quyền đọc repo):
|
|
11
|
+
Thư viện là gói npm [`tourfp`](https://www.npmjs.com/package/tourfp), chỉ gồm file `tourfp.js`, không có phụ thuộc và không cần bước build.
|
|
14
12
|
|
|
15
13
|
```bash
|
|
16
|
-
npm install
|
|
14
|
+
npm install tourfp
|
|
17
15
|
```
|
|
18
16
|
|
|
19
17
|
Rồi nạp vào app theo một trong hai cách:
|
|
@@ -28,22 +26,28 @@ Với Angular, nên khai báo trong `angular.json` để thư viện được n
|
|
|
28
26
|
"scripts": ["node_modules/tourfp/tourfp.js"]
|
|
29
27
|
```
|
|
30
28
|
|
|
31
|
-
|
|
29
|
+
Hoặc không cần cài, nạp bằng một thẻ script qua CDN:
|
|
30
|
+
|
|
31
|
+
```html
|
|
32
|
+
<script src="https://cdn.jsdelivr.net/npm/tourfp@0.3.1/tourfp.js"></script>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Để dùng thử nhanh trên một trang đang mở (trang phải cho phép script từ `cdn.jsdelivr.net`), dán dòng này vào console:
|
|
32
36
|
|
|
33
37
|
```js
|
|
34
|
-
|
|
35
|
-
window.TOURFP_PLAYER = true; // chỉ phát, không hiện thanh ghi tour
|
|
38
|
+
document.head.appendChild(Object.assign(document.createElement('script'), { src: 'https://cdn.jsdelivr.net/npm/tourfp@0.3.1/tourfp.js' }));
|
|
36
39
|
```
|
|
37
40
|
|
|
38
|
-
|
|
41
|
+
Tuỳ chọn, đặt trước khi nạp thư viện:
|
|
39
42
|
|
|
40
|
-
```
|
|
41
|
-
|
|
43
|
+
```js
|
|
44
|
+
window.TOURFP_SERVER = 'https://tour.example.com'; // máy chủ lưu tour, mặc định http://localhost:8000
|
|
45
|
+
window.TOURFP_PLAYER = true; // chỉ phát, không hiện thanh ghi tour
|
|
42
46
|
```
|
|
43
47
|
|
|
44
48
|
## Dùng thử
|
|
45
49
|
|
|
46
|
-
|
|
50
|
+
Sau khi nạp thư viện (xem Cài đặt), một thanh điều khiển hiện ra ở góc dưới trái
|
|
47
51
|
(kéo đi chỗ khác được):
|
|
48
52
|
|
|
49
53
|
- **● Ghi** — thao tác như bình thường, mỗi cú bấm thành một bước và vẫn chạy thật.
|
|
@@ -85,6 +89,7 @@ Mở <http://localhost:8000>:
|
|
|
85
89
|
- **Chạy thử** — chép một đoạn mã gồm cả thư viện lẫn tour; mở màn bắt đầu của tour, dán vào console là tour phát ngay.
|
|
86
90
|
Khi chạy thử, thanh ghi tour không hiện (chế độ chỉ phát); gọi `TourFP.showPanel()` nếu cần hiện lại.
|
|
87
91
|
- **Xoá**, **Tải JSON**, và xem danh sách bước của từng tour.
|
|
92
|
+
- **Sửa tên**: bấm vào tên một bước để đặt tên hiển thị cho bước đó (để trống thì dùng lại tên tự lấy từ giao diện); nút **Sửa tên tour** đổi tên và mô tả của tour. Tên đã đặt được dùng trong lời hướng dẫn khi phát và trong việc tìm tour của trợ lý.
|
|
88
93
|
|
|
89
94
|
Cấu hình bằng biến môi trường: `TOURFP_TARGET` là trang web tour chạy trên đó (mặc định `https://tcinvest.tcbs.com.vn`),
|
|
90
95
|
`TOURFP_DATA` là thư mục chứa file SQLite.
|
|
@@ -94,6 +99,54 @@ Khi thư viện được cài sẵn trong app và app cho phép gọi tới máy
|
|
|
94
99
|
(mặc định `http://localhost:8000`). Trang TCInvest hiện chặn mọi kết nối ra ngoài danh sách cho phép của nó, nên với trang đó
|
|
95
100
|
phải dùng cách dán JSON và dán mã chạy thử.
|
|
96
101
|
|
|
102
|
+
### Trợ lý tìm tour
|
|
103
|
+
|
|
104
|
+
Người dùng nói muốn tới màn hình nào, máy chủ tìm tour phù hợp trong danh sách và trả về hai cách đi:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
curl -X POST http://localhost:8000/api/assist -H 'Content-Type: application/json' -d '{"query": "tôi muốn nộp tiền", "layout": "mobile"}'
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Câu trả lời gồm `tour` (tour được chọn), `to_step` (dừng sau bước thứ mấy; mặc định là bước cuối, hoặc bước người dùng nêu như
|
|
111
|
+
"tới bước 3"), `reason` (lý do, để hiện cho người dùng) và `options`:
|
|
112
|
+
|
|
113
|
+
- `guide` — **Hướng dẫn từng bước**: chỉ từng thao tác, người dùng tự bấm.
|
|
114
|
+
- `direct` — **Vào thẳng màn hình**: thư viện tự đưa tới nơi. Nếu bước cuối chuyển sang một trang khác thì vào thẳng trang đó
|
|
115
|
+
bằng đường dẫn; nếu không thì tự bấm các bước trên trang cuối. Gặp bước cần người dùng nhập, hoặc không tìm thấy đích,
|
|
116
|
+
nó chuyển sang hướng dẫn từng bước từ chỗ đó.
|
|
117
|
+
|
|
118
|
+
Mỗi lựa chọn kèm `run_url` (mở trang với `?tourfp=<mã>&mode=<guide|direct>&to=<bước>`, dùng khi thư viện đã cài trong app) và
|
|
119
|
+
`snippet_url` (mã chạy thử để dán vào console). Trên trang quản trị có ô **Trợ lý tìm tour** để thử ngay.
|
|
120
|
+
|
|
121
|
+
Trong app đã cài thư viện và gọi được máy chủ, có thể dùng thẳng:
|
|
122
|
+
|
|
123
|
+
```js
|
|
124
|
+
TourFP.ask('tôi muốn nộp tiền'); // hỏi trợ lý rồi hiện hai lựa chọn cho người dùng
|
|
125
|
+
TourFP.start({ mode: 'direct', to: 3 }); // chạy tour đang nạp: vào thẳng, dừng sau bước 3
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Chế độ vào thẳng tự bấm thay người dùng. Với tour đi qua thao tác có hậu quả (đặt lệnh, xác nhận giao dịch), nên đặt `to` dừng
|
|
129
|
+
trước các bước đó, hoặc chỉ đưa ra lựa chọn hướng dẫn từng bước.
|
|
130
|
+
|
|
131
|
+
### Nối với mô hình AI
|
|
132
|
+
|
|
133
|
+
Mặc định trợ lý tìm bằng so khớp từ khoá, không gọi AI. Bật AI bằng biến môi trường `TOURFP_AI`:
|
|
134
|
+
|
|
135
|
+
| `TOURFP_AI` | Nối tới | Cần đặt thêm |
|
|
136
|
+
|---|---|---|
|
|
137
|
+
| `off` (mặc định) | Không gọi AI | |
|
|
138
|
+
| `bedrock` | Amazon Bedrock, qua API Converse | `TOURFP_AI_MODEL`, `AWS_REGION`, thông tin đăng nhập AWS; cài `boto3` |
|
|
139
|
+
| `litellm` | LiteLLM proxy, hoặc máy chủ nào nói giao thức `chat/completions` | `TOURFP_AI_MODEL`, `TOURFP_LITELLM_URL`, `TOURFP_LITELLM_KEY` (nếu proxy yêu cầu) |
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
TOURFP_AI=litellm TOURFP_AI_MODEL=<tên mô hình> TOURFP_LITELLM_URL=http://localhost:4000 uvicorn main:app --app-dir server --port 8000
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Mô hình chỉ nhận tên tour, mô tả và nhãn của từng bước, cùng câu người dùng nhập; không nhận dấu vân tay. Câu trả lời của mô
|
|
146
|
+
hình luôn được kiểm lại (tour phải có thật, số bước phải nằm trong tour). Khi mô hình lỗi hoặc trả lời sai dạng, trợ lý tự
|
|
147
|
+
chuyển sang so khớp từ khoá và ghi lý do vào `ai_error`. Thêm một nhà cung cấp khác bằng cách viết một lớp có hàm
|
|
148
|
+
`complete(system, user)` trong `server/ai.py`.
|
|
149
|
+
|
|
97
150
|
Máy chủ không có đăng nhập; chỉ nên chạy trên `127.0.0.1`.
|
|
98
151
|
|
|
99
152
|
## Giới hạn hiện tại
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tourfp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Ghi và phát tour hướng dẫn trên giao diện web bằng dấu vân tay phần tử, không cần sửa mã nguồn của app.",
|
|
5
5
|
"main": "tourfp.js",
|
|
6
6
|
"browser": "tourfp.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
],
|
|
21
21
|
"repository": {
|
|
22
22
|
"type": "git",
|
|
23
|
-
"url": "git+https://github.com/ngquangdat/
|
|
23
|
+
"url": "git+https://github.com/ngquangdat/tourfp.git"
|
|
24
24
|
},
|
|
25
25
|
"license": "UNLICENSED"
|
|
26
26
|
}
|
package/tourfp.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
/* TourFP 0.
|
|
1
|
+
/* TourFP 0.3.1 — bản thử: ghi tour theo thao tác và khớp phần tử bằng "dấu vân tay".
|
|
2
2
|
* - Không dùng class/id. Không phụ thuộc thư viện.
|
|
3
3
|
* - Chữ lấy từ API (tên, tên kế hoạch…) và chữ số bị loại khỏi dấu vân tay.
|
|
4
4
|
* Để lọc được, file này phải được nạp TRƯỚC khi app gọi API (đặt trong <head>).
|
|
5
5
|
* - Ghi: bấm bình thường = bước "thao tác" (cú bấm vẫn chạy, app chuyển màn như thật);
|
|
6
6
|
* Alt+bấm = bước "chỉ tô sáng" (cú bấm bị chặn).
|
|
7
7
|
* - Phát: tô sáng đích, chờ người dùng tự bấm rồi mới sang bước sau; tự chờ màn mới hiện ra.
|
|
8
|
-
*
|
|
8
|
+
* start({ mode: 'guide' | 'direct', to }) chạy tới bước thứ `to`: dẫn từng bước, hoặc thư viện tự đưa tới nơi.
|
|
9
|
+
* - Cấu hình (đặt trước khi nạp): window.TOURFP_SERVER = địa chỉ máy chủ lưu tour; window.TOURFP_PLAYER = true để ẩn thanh ghi.
|
|
10
|
+
* API: TourFP.record() | stop() | play() | start(opts) | load(steps) | export() | save() | run(id, opts) | ask(query)
|
|
11
|
+
* | capture(el) | find(fp) | showPanel(on) | destroy()
|
|
9
12
|
*/
|
|
10
13
|
(() => {
|
|
11
14
|
if (typeof window === 'undefined' || typeof document === 'undefined') return; // được import ở phía máy chủ (SSR, công cụ build): không làm gì
|
|
@@ -75,7 +78,7 @@
|
|
|
75
78
|
// chỉ cần vài mẩu đầu: với khối lớn (thẻ, bảng) đó là tiêu đề
|
|
76
79
|
for (let n; (n = w.nextNode()) && nodes < 40 && out.length < 6;) {
|
|
77
80
|
const v = n.nodeValue;
|
|
78
|
-
if (!v.trim() || (n.parentElement && n.parentElement.closest('svg,script,style'))) continue;
|
|
81
|
+
if (!v.trim() || (n.parentElement && n.parentElement.closest('svg,script,style,mat-icon,.material-icons'))) continue;
|
|
79
82
|
nodes++;
|
|
80
83
|
if (/\d/.test(v)) digits = true;
|
|
81
84
|
for (const seg of v.split(/\d[\d.,%\/:-]*/)) { const s = norm(seg); if (s.length >= 2 && hasLetters(s)) { out.push(s.slice(0, 60)); num.push(/\d/.test(v)); } }
|
|
@@ -417,6 +420,7 @@
|
|
|
417
420
|
const steps = []; // { kind: 'click' | 'hover' | 'input' | 'info', fp, note }
|
|
418
421
|
let recording = false, playing = -1, raf = 0, target = null, token = 0, pending = null, lastCap = 0;
|
|
419
422
|
let hoverEl = null, hoverT = 0, lastDown = 0, popups = [], advancing = false;
|
|
423
|
+
let directRun = 0, fullSteps = null, choice = null; // lượt "vào thẳng" đang chạy; bản đầy đủ của tour khi chỉ chạy tới bước x; hai lựa chọn đang chờ
|
|
420
424
|
// khi đang ghi: menu bật ra do rê chuột (không phải do bấm) thì nhớ phần tử đang được rê lên
|
|
421
425
|
const popMo = new MutationObserver(list => {
|
|
422
426
|
// bỏ qua nếu vừa có cú bấm (trong 1,5 giây, hoặc từ lúc chuột vào phần tử đang rê): khi đó thứ bật ra là do bấm
|
|
@@ -467,7 +471,8 @@
|
|
|
467
471
|
const tipText = mk('div', 'margin-bottom:4px;white-space:pre-line');
|
|
468
472
|
const tipMeta = mk('div', 'margin-bottom:8px;font-size:11px;opacity:.55');
|
|
469
473
|
const bNext = mk('button', btnCss + 'pointer-events:auto;background:#e5484d;', 'Tiếp'), bStop = mk('button', btnCss + 'pointer-events:auto;background:#ddd;color:#111;margin-left:6px', 'Dừng');
|
|
470
|
-
|
|
474
|
+
const bClose = mk('button', btnCss + 'pointer-events:auto;background:none;color:#666;margin-left:6px;display:none', 'Đóng');
|
|
475
|
+
tip.append(tipText, tipMeta, bNext, bStop, bClose);
|
|
471
476
|
// chế độ chỉ phát (chạy thử từ trang quản trị, hoặc mở trang với ?tourfp=<mã>): không hiện thanh ghi tour
|
|
472
477
|
const PLAYER = !!window.TOURFP_PLAYER || new URLSearchParams(location.search).has('tourfp');
|
|
473
478
|
if (PLAYER) panel.style.display = 'none';
|
|
@@ -596,7 +601,11 @@
|
|
|
596
601
|
setInfo(); cancelAnimationFrame(raf); loop();
|
|
597
602
|
}
|
|
598
603
|
function stop() { recording = false; pending = null; popMo.disconnect(); bRec.textContent = '● Ghi'; bRec.style.background = '#2a2f3a'; target = null; box.style.display = 'none'; cancelAnimationFrame(raf); setInfo(); }
|
|
599
|
-
function stopPlay(msg) {
|
|
604
|
+
function stopPlay(msg) {
|
|
605
|
+
token++; directRun++; playing = -1; atEnd = false; choice = null; bClose.style.display = 'none';
|
|
606
|
+
if (fullSteps) { steps.length = 0; steps.push(...fullSteps); fullSteps = null; }
|
|
607
|
+
tip.style.display = 'none'; box.style.display = 'none'; target = null; cancelAnimationFrame(raf); setInfo(msg);
|
|
608
|
+
}
|
|
600
609
|
|
|
601
610
|
// chờ đến khi màn hình của bước hiện ra và tìm được đích
|
|
602
611
|
// alt: dấu vân tay của bước kế; nếu màn hình đã ở trạng thái của bước kế thì bỏ qua bước này
|
|
@@ -632,7 +641,7 @@
|
|
|
632
641
|
async function show(i) {
|
|
633
642
|
if (i >= steps.length) return stopPlay(`Xong tour · ${steps.length} bước`);
|
|
634
643
|
playing = i; target = null; advancing = false; box.style.display = 'none';
|
|
635
|
-
const st = steps[i], nx = steps[i + 1], name = nameOf(st.fp), head = `Bước ${i + 1}/${steps.length}`;
|
|
644
|
+
const st = steps[i], nx = steps[i + 1], name = st.title || nameOf(st.fp), head = `Bước ${i + 1}/${steps.length}`;
|
|
636
645
|
// bước rê chuột mà đích của bước sau đã hiện sẵn thì không cần rê nữa
|
|
637
646
|
if (st.kind === 'hover' && nx && find(nx.fp).el) return show(i + 1);
|
|
638
647
|
box.style.borderColor = '#e5484d'; box.style.boxShadow = '0 0 0 9999px rgba(0,0,0,.5)';
|
|
@@ -727,6 +736,75 @@
|
|
|
727
736
|
if (first >= 0) show(first); else goStart();
|
|
728
737
|
}
|
|
729
738
|
|
|
739
|
+
// ---------- vào thẳng: thư viện tự làm các bước thay người dùng ----------
|
|
740
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
741
|
+
const navTo = path => { history.pushState({}, '', path); dispatchEvent(new PopStateEvent('popstate', { state: null })); };
|
|
742
|
+
const pressEsc = () => document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true }));
|
|
743
|
+
// giả lập rê chuột: sự kiện "enter" không nổi bọt nên bắn riêng cho phần tử và vài cấp cha của nó
|
|
744
|
+
function hoverOn(el) {
|
|
745
|
+
for (const t of ['pointerover', 'mouseover', 'mousemove']) el.dispatchEvent(new MouseEvent(t, { bubbles: true, cancelable: true, view: window }));
|
|
746
|
+
for (let p = el, i = 0; p && p !== document.body && i < 4; p = p.parentElement, i++) for (const t of ['pointerenter', 'mouseenter']) p.dispatchEvent(new MouseEvent(t, { bubbles: false, view: window }));
|
|
747
|
+
}
|
|
748
|
+
async function direct() {
|
|
749
|
+
if (recording) stop();
|
|
750
|
+
if (!steps.length) return setInfo('Chưa có bước nào');
|
|
751
|
+
const my = ++directRun, alive = () => my === directRun;
|
|
752
|
+
const n = steps.length, last = steps[n - 1];
|
|
753
|
+
token++; playing = -1; target = null; atEnd = false; box.style.display = 'none'; cancelAnimationFrame(raf);
|
|
754
|
+
tip.style.display = 'block'; tip.style.left = '12px'; tip.style.top = '12px';
|
|
755
|
+
const say = t => { rich(tipText, t); tipMeta.textContent = ''; setBtns(null, 'Dừng'); };
|
|
756
|
+
say('Đang đưa bạn tới màn hình…');
|
|
757
|
+
const finish = () => { say('Đã đưa bạn tới nơi.'); setBtns(null, null); setTimeout(() => { if (alive()) stopPlay('Đã tới nơi'); }, 1600); };
|
|
758
|
+
|
|
759
|
+
// 1) Bước cuối chuyển sang một trang khác và không mở hộp thoại: vào thẳng trang đó bằng đường dẫn.
|
|
760
|
+
const after = last.after;
|
|
761
|
+
if (after && !after.dialog && !after.step && after.path !== last.fp.ctx.path) {
|
|
762
|
+
if (topDialog()) pressEsc();
|
|
763
|
+
if (location.pathname !== after.path) navTo(after.path);
|
|
764
|
+
for (let t = 0; t < 15 && alive() && context().path !== after.path; t++) await sleep(200);
|
|
765
|
+
if (!alive()) return;
|
|
766
|
+
if (context().path !== after.path) return location.assign(after.path); // app không tự chuyển: tải thẳng trang đó
|
|
767
|
+
return finish();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// 2) Còn lại: chỉ làm "chặng cuối", tức các bước liền nhau cùng đường dẫn với bước cuối. Các trang trước đó bỏ qua.
|
|
771
|
+
let first = n - 1;
|
|
772
|
+
while (first > 0 && steps[first - 1].fp.ctx.path === last.fp.ctx.path) first--;
|
|
773
|
+
if (steps[first].fp.ctx.dialog) return play(); // chặng cuối bắt đầu trong một hộp thoại: không tới được bằng đường dẫn, quay về dẫn từng bước
|
|
774
|
+
const c = context();
|
|
775
|
+
let start = -1;
|
|
776
|
+
for (let i = first; i < n && start < 0; i++) if (sameCtx(steps[i].fp.ctx, c) && find(steps[i].fp).el) start = i; // đang đứng sẵn giữa chặng
|
|
777
|
+
if (start < 0) {
|
|
778
|
+
if (topDialog()) pressEsc();
|
|
779
|
+
if (location.pathname !== steps[first].fp.ctx.path) navTo(steps[first].fp.ctx.path);
|
|
780
|
+
start = first;
|
|
781
|
+
}
|
|
782
|
+
for (let i = start; i < n; i++) {
|
|
783
|
+
const st = steps[i];
|
|
784
|
+
if (st.kind === 'info') continue; // bước chỉ để xem, không có gì phải làm
|
|
785
|
+
say(`Đang đưa bạn tới màn hình… (${i + 1}/${n})`);
|
|
786
|
+
const r = await waitFind(st.fp, 6000, steps.slice(i + 1, i + 3).map((x, k) => ({ fp: x.fp, idx: i + 1 + k })));
|
|
787
|
+
if (!r || !alive()) return;
|
|
788
|
+
if (r.skipTo != null) { i = r.skipTo - 1; continue; } // bước này đã được làm sẵn
|
|
789
|
+
if (!r.el || st.kind === 'input') { directRun++; return show(i); } // không tự làm được (không thấy đích, hoặc cần người dùng nhập): dẫn từng bước từ đây
|
|
790
|
+
if (st.kind === 'hover') hoverOn(r.el);
|
|
791
|
+
else { r.el.scrollIntoView({ block: 'center', inline: 'nearest' }); r.el.click(); }
|
|
792
|
+
await sleep(350);
|
|
793
|
+
if (!alive()) return;
|
|
794
|
+
}
|
|
795
|
+
finish();
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Bắt đầu một lượt: mode 'guide' (dẫn từng bước, mặc định) hoặc 'direct' (vào thẳng); to = dừng sau bước thứ mấy (mặc định: hết tour).
|
|
799
|
+
function start(opts = {}) {
|
|
800
|
+
if (recording) stop();
|
|
801
|
+
if (!steps.length) return setInfo('Chưa có bước nào');
|
|
802
|
+
if (fullSteps) { steps.length = 0; steps.push(...fullSteps); fullSteps = null; }
|
|
803
|
+
const to = Math.min(Math.max(parseInt(opts.to, 10) || steps.length, 1), steps.length);
|
|
804
|
+
if (to < steps.length) { fullSteps = steps.slice(); steps.length = to; }
|
|
805
|
+
return opts.mode === 'direct' ? direct() : play();
|
|
806
|
+
}
|
|
807
|
+
|
|
730
808
|
// ---------- máy chủ lưu tour (thư mục server/) ----------
|
|
731
809
|
const SERVER = String(window.TOURFP_SERVER || 'http://localhost:8000').replace(/\/$/, '');
|
|
732
810
|
// Chép vào clipboard NGAY trong cú bấm. Nhiều trang (kể cả TCInvest) từ chối quyền ghi clipboard kiểu mới,
|
|
@@ -768,20 +846,42 @@
|
|
|
768
846
|
}
|
|
769
847
|
}
|
|
770
848
|
// tải một tour từ máy chủ theo mã rồi phát
|
|
771
|
-
async function run(id) {
|
|
849
|
+
async function run(id, opts = {}) {
|
|
772
850
|
const r = await fetch(`${SERVER}/api/tours/${encodeURIComponent(id)}`);
|
|
773
851
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
774
852
|
const t = await r.json();
|
|
775
|
-
steps.length = 0; steps.push(...t.steps); setInfo();
|
|
776
|
-
|
|
853
|
+
fullSteps = null; steps.length = 0; steps.push(...t.steps); setInfo();
|
|
854
|
+
start(opts);
|
|
855
|
+
}
|
|
856
|
+
// Hỏi trợ lý trên máy chủ "muốn tới màn hình nào", rồi cho người dùng chọn: dẫn từng bước hay vào thẳng.
|
|
857
|
+
async function ask(query) {
|
|
858
|
+
let a;
|
|
859
|
+
try {
|
|
860
|
+
const r = await fetch(SERVER + '/api/assist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, layout: context().layout }) });
|
|
861
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
862
|
+
a = await r.json();
|
|
863
|
+
} catch (e) {
|
|
864
|
+
a = { matched: false, reason: 'Không gọi được trợ lý.' };
|
|
865
|
+
}
|
|
866
|
+
stopPlay();
|
|
867
|
+
tip.style.display = 'block'; tip.style.left = '12px'; tip.style.top = '12px'; tipMeta.textContent = '';
|
|
868
|
+
bClose.style.display = '';
|
|
869
|
+
if (!a.matched) { rich(tipText, a.reason || 'Chưa tìm thấy tour phù hợp.'); setBtns(null, null); choice = {}; return a; }
|
|
870
|
+
rich(tipText, `«${a.tour.name}»` + (a.to_step < a.tour.step_count ? ` · tới bước ${a.to_step}/${a.tour.step_count}` : '') + '\n' + a.reason);
|
|
871
|
+
setBtns(a.options[0].label, a.options[1].label);
|
|
872
|
+
const go = mode => run(a.tour.id, { mode, to: a.to_step }).catch(() => { rich(tipText, 'Không tải được tour.'); });
|
|
873
|
+
choice = { guide: () => go('guide'), direct: () => go('direct') };
|
|
874
|
+
return a;
|
|
777
875
|
}
|
|
778
876
|
bSave.onclick = save;
|
|
779
877
|
if (!PLAYER) probe(); // biết trước nên gửi hay chép, để cú bấm đầu tiên vào nút đã làm đúng việc
|
|
780
878
|
bRec.onclick = () => (recording ? stop() : record());
|
|
781
879
|
bPlay.onclick = play;
|
|
782
|
-
bClr.onclick = () => { steps.length = 0; stopPlay(); };
|
|
783
|
-
|
|
784
|
-
|
|
880
|
+
bClr.onclick = () => { fullSteps = null; steps.length = 0; stopPlay(); };
|
|
881
|
+
const pick = k => { const f = choice[k]; choice = null; bClose.style.display = 'none'; if (f) f(); };
|
|
882
|
+
bNext.onclick = () => { if (choice) pick('guide'); else if (atEnd) { atEnd = false; goStart(); } else show(playing + 1); };
|
|
883
|
+
bStop.onclick = () => { if (choice) pick('direct'); else stopPlay(); };
|
|
884
|
+
bClose.onclick = () => stopPlay();
|
|
785
885
|
const L = [['mousemove', onMove], ['pointerdown', onDown], ['mousedown', onUp], ['pointerup', onUp], ['mouseup', onUp], ['click', onClick]];
|
|
786
886
|
for (const [n, h] of L) document.addEventListener(n, h, true);
|
|
787
887
|
|
|
@@ -793,13 +893,13 @@
|
|
|
793
893
|
}
|
|
794
894
|
|
|
795
895
|
window.TourFP = {
|
|
796
|
-
capture, captureAt: el => capture(clickableRoot(el)), isClickable, save, run,
|
|
896
|
+
capture, captureAt: el => capture(clickableRoot(el)), isClickable, save, run, start, direct, ask,
|
|
797
897
|
showPanel: (on = true) => { panel.style.display = on ? 'flex' : 'none'; }, find, score, context, clickables, record, stop, play, destroy, steps,
|
|
798
898
|
apiStats: () => ({ responses: api.responses, values: api.set.size }),
|
|
799
899
|
isApiValue,
|
|
800
900
|
state: () => ({ recording, playing, steps: steps.length, target: target ? nameOf(capture(target)) : null, tip: tip.style.display === 'block' ? tipText.textContent : '' }),
|
|
801
901
|
export: () => JSON.stringify(steps, null, 2),
|
|
802
|
-
load: a => { steps.length = 0; steps.push(...a); setInfo(); },
|
|
902
|
+
load: a => { fullSteps = null; steps.length = 0; steps.push(...a); setInfo(); },
|
|
803
903
|
};
|
|
804
904
|
|
|
805
905
|
// vừa bị tải lại trang giữa lúc đưa về màn bắt đầu: nạp lại tour và phát tiếp (không điều hướng lần nữa)
|
|
@@ -812,7 +912,7 @@
|
|
|
812
912
|
}
|
|
813
913
|
} catch (_) { /* không đọc được thì bỏ qua */ }
|
|
814
914
|
|
|
815
|
-
// mở trang với ?tourfp=<m
|
|
816
|
-
const
|
|
817
|
-
if (wanted && !steps.length) run(wanted).catch(() => setInfo('Không tải được tour ' + wanted));
|
|
915
|
+
// mở trang với ?tourfp=<mã>[&mode=guide|direct][&to=<bước>]: tải tour đó từ máy chủ và chạy luôn
|
|
916
|
+
const qs = new URLSearchParams(location.search), wanted = qs.get('tourfp');
|
|
917
|
+
if (wanted && !steps.length) run(wanted, { mode: qs.get('mode'), to: qs.get('to') }).catch(() => setInfo('Không tải được tour ' + wanted));
|
|
818
918
|
})();
|