🛠️ Tizia Dev · Hướng dẫn phát triển app
App Tizia là một trang web tĩnh (SPA) đóng gói thành .zip. Bạn viết bằng gì cũng được — React, Vue, Svelte hay HTML thuần — miễn build ra được index.html cùng đống asset. Tizia dùng đúng chuẩn app của AI Portal, nên một gói cài được cả hai nơi.
1. Làm thử trong 5 phút
Tạo hai file này rồi nén lại thành hello.zip:
{
"alias": "hello-tizia",
"name": "Hello Tizia",
"description": "App đầu tiên của tôi",
"icon": "👋",
"version": "1.0.0"
}<!doctype html>
<html lang="vi">
<head><meta charset="utf-8"><title>Hello Tizia</title></head>
<body style="font-family:system-ui;padding:32px">
<h1>Xin chào 👋</h1>
<p id="who">Đang đọc danh tính…</p>
<script>
// Tizia chèn sẵn biến này trước khi trang chạy.
var u = window.__PORTAL_USER__;
document.getElementById('who').textContent =
u ? 'Chào ' + u.name + ' (id ' + u.id + ')' : 'Bạn chưa đăng nhập';
</script>
</body>
</html>zip -r hello.zip manifest.json public/
Kéo hello.zip vào trang Portal Apps:

Mở app lên, nó chào đúng tên người đang đăng nhập — danh tính do Tizia đưa sang:

2. Tải app mẫu về
Nhanh hơn nữa: tải một gói mẫu đã đóng sẵn, kéo thả vào Tizia để xem nó chạy, rồi giải nén ra sửa thành app của bạn. Mỗi gói là một file HTML duy nhất, không cần cài đặt gì, không cần build — mở bằng trình soạn thảo là đọc hiểu được ngay.
App tối giản nhất chạy được: một manifest, một trang HTML.
Học được: cách đọc __PORTAL_USER__ để biết ai đang mở app.
⬇ Tải hello-tizia.zipĐồng hồ tập trung 25/5 theo chu kỳ, có âm báo hết giờ.
Học được: quản lý trạng thái theo thời gian, giao diện gọn cho màn hình nhỏ.
⬇ Tải pomodoro.zipTự nhập bộ thẻ, lật thẻ học, đánh dấu đã thuộc.
Học được: lưu dữ liệu người dùng bằng localStorage.
⬇ Tải flashcards.zipGõ Markdown bên trái, xem kết quả bên phải, xuất file .md.
Học được: bố cục hai cột và cho người dùng tải file về.
⬇ Tải md-notes.zipNhập một màu, sinh bảng màu hài hoà, copy mã hex/rgb/hsl.
Học được: công cụ thuần tính toán, không cần lưu gì cả.
⬇ Tải color-palette.ziphello-tizia.zip → kéo thả vào Portal Apps → mở app. Sau đó giải nén, sửa alias và name trong manifest.json thành của bạn, sửa public/ tuỳ ý, nén lại rồi cài đè. Đó là toàn bộ vòng làm việc.3. Cấu trúc gói .zip
my-app.zip
├── manifest.json ← bắt buộc, nằm ở gốc (hoặc trong 1 thư mục con duy nhất)
└── public/
├── index.html ← bắt buộc, là điểm vào
└── assets/ ← JS/CSS/ảnh đã build
├── index-abc123.js
└── index-abc123.cssTizia chỉ giải nén những gì nằm trong public/. File để ngoài (README, thư mục schema/, mã nguồn…) bị bỏ qua, không gây lỗi. Manifest được đọc rồi lưu vào cơ sở dữ liệu.
| Giới hạn | Giá trị |
|---|---|
| File .zip | tối đa 30 MB |
| Sau khi giải nén | tối đa 100 MB |
| Số file | tối đa 2000 |
4. manifest.json
| Trường | Mức | Ý nghĩa |
|---|---|---|
| alias | bắt buộc | Định danh app: chữ thường + gạch nối, 2–41 ký tự, bắt đầu bằng chữ. Là URL của app. Thiếu thì lấy id. |
| name | bắt buộc | Tên hiển thị trên thẻ app (tối đa 120 ký tự). |
| description | nên có | Mô tả ngắn hiện dưới tên (tối đa 500 ký tự). |
| icon | nên có | Dùng EMOJI (👋, 🧮). Tên icon Lucide kiểu "FileText" vẫn nhận nhưng Tizia hiển thị 🧩 thay thế. |
| version | nên có | Ví dụ 1.0.0. Chỉ để hiển thị; cài đè không cần tăng version. |
| hasBackend | tuỳ chọn | true nếu app cần backend riêng. Phải khai kèm apiProxyTarget. |
| apiProxyTarget | tuỳ chọn | URL TUYỆT ĐỐI tới backend của bạn (http/https). Chấp nhận tên cũ base_url. |
| supported_languages | tuỳ chọn | Ví dụ ["vi","en"]. Hiện Tizia luôn báo locale vi. |
| type, hasFrontendOnly, functions… | bỏ qua | Giữ được để tương thích AI Portal; Tizia lưu nguyên manifest nhưng chưa dùng. |
5. Host đưa gì cho app
Khi phục vụ index.html, Tizia chèn thêm vào <head> mấy thứ sau. App chỉ việc đọc — không cần gọi API nào để biết mình đang chạy ở đâu, ai đang dùng:
| Biến | Nội dung |
|---|---|
| <base href> | Trỏ vào thư mục app. Nhờ nó mà ./assets/… trong index.html luôn resolve đúng. |
| window.__PORTAL_USER__ | { id, email, name } của người đang mở app. id là chuỗi, ví dụ "23". |
| window.__WRITE_API_BASE__ | Đường dẫn gốc gọi backend của app: /api/apps/<alias>. |
| window.__DATA_API_BASE__ | Giống trên — có hai tên để tương thích app viết cho AI Portal. |
| window.__PORTAL_THEME__ | "dark" hoặc "light" khi mở kèm ?theme=. Tizia cũng thêm class đó vào thẻ <html>. |
| window.__AI_PORTAL_LOCALE__ | Hiện luôn là "vi". |
| window.__PORTAL_BASE_PATH__ | Chỉ xuất hiện khi Tizia chạy dưới thư mục con. Bình thường không có. |
Nhìn thấy tận mắt
Mở app rồi xem mã nguồn trang, phần đầu sẽ trông như thế này:
<head>
<base href="/portal-apps/u/23/hello-tizia/">
<script>window.__WRITE_API_BASE__="/api/apps/hello-tizia";
window.__DATA_API_BASE__="/api/apps/hello-tizia";</script>
<script>window.__AI_PORTAL_LOCALE__="vi";</script>
<script>window.__PORTAL_USER__={"id":"23","email":"","name":"Nguyễn Văn A"};</script>
… phần <head> gốc của bạn giữ nguyên …X-User-Id (mục 7) — người dùng sửa biến trong trình duyệt được, sửa header thì không.__PORTAL_USER__ gần như luôn tồn tại. Dù vậy vẫn nên viết phòng hờ u ? … : …, để app còn chạy được khi bạn mở thử ở máy hoặc đem sang host khác.6. Lưu dữ liệu ở đâu
Tizia không cấp sẵn kho dữ liệu cho app. Bạn có ba lựa chọn, chọn theo mức độ nghiêm túc của dữ liệu:
| Cách | Hợp với | Nhược điểm |
|---|---|---|
| localStorage | Nháp, cài đặt cá nhân, app dùng một mình như máy tính, ghi chú. | Mất khi đổi máy/xoá cache. Không chia sẻ được. |
| Backend riêng (mục 7) | Dữ liệu thật, nhiều người, cần thống kê hay chấm điểm. | Bạn phải tự dựng và tự lo vận hành. |
| Không lưu gì | Công cụ tính toán, chuyển đổi, tạo mã QR… | Không có gì để mất — thường là lựa chọn tốt nhất. |
localStorage.setItem('myapp:' + __PORTAL_USER__.id + ':notes', …) — máy tính chung ở trường thì hai học sinh mới không đọc nhầm dữ liệu của nhau.7. App có backend riêng
Tizia không chạy code backend nằm trong file zip. Backend của bạn phải là một dịch vụ riêng đang chạy sẵn; bạn khai địa chỉ nó vào apiProxyTarget, còn Tizia làm proxy và gắn danh tính người dùng vào từng request.
{
"alias": "my-app",
"name": "My App",
"hasBackend": true,
"apiProxyTarget": "https://api.vidu.vn/my-app"
}Phía app gọi thế nào
const base = window.__WRITE_API_BASE__ || ''; // "/api/apps/my-app"
export async function getSources() {
const r = await fetch(base + '/sources', { credentials: 'include' });
if (r.status === 401) throw new Error('Phiên đăng nhập đã hết');
return r.json();
}Phía backend đọc danh tính thế nào
app.get('/sources', (req, res) => {
const userId = req.header('X-User-Id'); // "23" — Tizia đã xác thực
if (!userId) return res.status(401).json({ error: 'no_identity' });
res.json({ items: db.listFor(userId) });
});Bốn luật phải nhớ
apiProxyTargetphải là URL tuyệt đối. Đường dẫn kiểu/my-apibị từ chối (502).- Backend trong mạng nội bộ (localhost, 10.x, 172.16–31.x, 192.168.x) mặc định bị chặn để tránh SSRF. Cần thì nhờ quản trị bật
PORTAL_APPS_ALLOW_INTERNAL. - Phần đuôi sau
/api/apps/<alias>được ghép nguyên xi vào target, kể cả query. Tizia không tự thêm/api. - Chỉ tin
X-User-*khi request thực sự đến từ Tizia. Đặt backend sau tường lửa, hoặc kiểm tra thêm một khoá bí mật dùng chung.
8. Build sao cho đúng
Ba lỗi dưới đây chiếm gần hết số ca "cài xong mở ra trắng trang". Đọc kỹ phần này tiết kiệm cho bạn cả buổi.
App nằm trong thư mục con, nên /assets/index.js (có gạch chéo đầu) sẽ 404.
export default defineConfig({
base: './', // ← quan trọng nhất
build: { outDir: 'public' },
})Kiểm tra nhanh: mở public/, thẻ script/link phải bắt đầu bằng ./assets/. Nếu thấy /assets/ hay /embed/ten-app/assets/ là còn sai.
Vite nướng thẳng giá trị VITE_* vào bundle. Nếu .env còn VITE_API_URL=http://localhost:4001 thì app cài lên Tizia vẫn cắm đầu gọi localhost của máy người dùng và fail sạch. Tệ hơn là .env thường bị gitignore nên máy bạn có, máy CI không — hai bên build ra hai kết quả khác nhau.
grep -r "localhost" public/assets/*.js | head
Luôn ưu tiên window.__WRITE_API_BASE__, chỉ rơi về địa chỉ mặc định khi biến này rỗng. Như vậy cùng một gói chạy được ở Tizia, ở AI Portal và ở máy bạn.
const base = (typeof window !== 'undefined' && window.__WRITE_API_BASE__) || import.meta.env.VITE_API_URL || '';
Cấu hình mẫu theo công cụ
| Công cụ | Cần đặt |
|---|---|
| Vite | base: './' |
| Create React App | "homepage": "." trong package.json |
| Next.js | Xuất tĩnh: output: 'export' + assetPrefix: './' |
| HTML thuần | Chỉ cần viết ./style.css, đừng viết /style.css |
9. Chạy thử ở máy trước khi đóng gói
Ở máy bạn không có Tizia nên __PORTAL_USER__ không tồn tại. Giả lập vài dòng là đủ để phát triển thoải mái:
<script>
// Giả lập môi trường Tizia khi chạy ở localhost.
if (location.hostname === 'localhost') {
window.__PORTAL_USER__ = { id: '1', email: 'dev@local', name: 'Dev' };
window.__WRITE_API_BASE__ = 'http://localhost:4000'; // backend dev của bạn
}
</script>Đoạn này nằm trong index.html nên vẫn theo vào bản build — nhưng vì có điều kiện localhost nên lên Tizia nó không chạy. Muốn sạch hơn thì đặt trong file riêng và loại khi build.
10. Cài đặt, cập nhật, công khai
Cài lần đầu
Kéo thả .zip vào trang Portal Apps. Cần đăng nhập và hồ sơ đã điền đủ.
Cập nhật
Cài lại đúng alias đó là xong — bản cũ bị thay hoàn toàn (Tizia giải nén ra thư mục tạm rồi mới tráo, nên app không bao giờ ở trạng thái nửa vời). URL không đổi, người dùng không phải làm gì.
Công khai cho mọi người
Mặc định app chỉ mình bạn thấy. Khi muốn phát hành cho cả trường, nhờ quản trị viên bấm công khai — lúc đó app xuất hiện trong mục "Apps cộng đồng" ở trang Ứng dụng:

Hai đường dẫn tới app — dùng đúng cái
| Đường dẫn | Dùng khi nào |
|---|---|
| /app/<alias> | ĐƯỜNG CHÍNH. App chạy trong khung Tizia: header ở trên, thanh chân ở dưới, người học không rơi ra ngoài. Đây là link nên đặt vào toà nhà, gửi cho người khác, ghim vào bài giảng. |
| /portal-apps/u/<id>/<alias>/ | App chiếm trọn màn hình, không có gì của Tizia bao quanh. Hợp khi bạn tự test, hoặc khi app cần toàn bộ chiều cao (bản đồ, 3D). |
href — trỏ vào /app/<alias> là app của bạn nằm ngay trong đó. Nhắn cho quản trị biết app nên nằm ở trường nào, toà nào.Tự động hoá bằng API
curl -X POST https://tizia.vn/api/portal-apps/install \ -b "tizia_sid=<cookie phiên của bạn>" \ -H "Content-Type: application/zip" \ --data-binary @my-app.zip
| Endpoint | Việc |
|---|---|
| POST /api/portal-apps/install | Cài hoặc cài đè app của chính bạn. |
| GET /api/portal-apps | Liệt kê app của bạn + app đã công khai + app có sẵn. |
| DELETE /api/portal-apps/:id | Gỡ app (chủ app hoặc quản trị). |
| POST /api/portal-apps/:id/publish | Công khai cho mọi người — cần quyền quản trị. |
11. Giao diện & trải nghiệm
- Nền tối là mặc định. Tizia dark-only ở hầu hết trang. App nền trắng vẫn mở được nhưng chói mắt khi người học đang đi từ trang tối sang. Nếu làm được cả hai nền thì đọc
__PORTAL_THEME__. - Người dùng là học sinh, sinh viên. Chữ tiếng Việt, câu ngắn, nút to. Tránh thuật ngữ tiếng Anh không chú giải.
- Chạy được trên màn hình hẹp. Nhiều em học bằng máy tính bảng hoặc điện thoại.
- Đừng tự mở cửa sổ mới hay chuyển hướng ra ngoài — app đang nằm trong khung của Tizia, làm vậy người học lạc mất đường về.
- Trạng thái rỗng phải nói được gì đó. "Chưa có dữ liệu" kèm một nút để bắt đầu, đừng để màn hình trống trơn.
12. Bảo mật & quyền riêng tư
- Không nhúng khoá API vào bundle. Mã frontend ai cũng đọc được. Khoá phải nằm ở backend của bạn.
- Không tự dựng form đăng nhập. Người dùng đã đăng nhập Tizia rồi; hỏi mật khẩu lần nữa là dấu hiệu lừa đảo.
- Backend phải chạy HTTPS nếu đặt ngoài mạng nội bộ.
- Kiểm tra quyền theo
X-User-Id, đừng tin id do app gửi lên trong body. - Chỉ xin đúng thứ cần. App tạo mã QR thì không có lý do gì đụng tới camera hay vị trí.
13. Xử lý sự cố
| Triệu chứng | Nguyên nhân hay gặp | Cách sửa |
|---|---|---|
| Mở app ra trang trắng | Asset dùng đường dẫn tuyệt đối nên 404. | Mở tab Network xem file .js có 404 không. Sửa base thành "./" rồi build lại. |
| App gọi localhost, hỏng ở máy người khác | File .env lúc dev bị nướng vào bundle. | Dọn .env (hoặc bỏ biến VITE_*) rồi build lại bản phát hành. |
| Backend trả 404 no_backend | Manifest chưa khai apiProxyTarget. | Thêm apiProxyTarget rồi CÀI ĐÈ lại — manifest chỉ đọc lúc cài. |
| Backend trả 502 unsafe_target | apiProxyTarget là đường dẫn tương đối, hoặc trỏ vào mạng nội bộ. | Đổi sang URL tuyệt đối công khai. Nếu buộc phải dùng dịch vụ nội bộ, nhờ quản trị bật cờ cho phép. |
| Backend trả 404 tuy dịch vụ vẫn sống | Ghép sai đường dẫn: Tizia không tự thêm tiền tố /api. | Viết luôn tiền tố vào target: https://…/my-app/api |
| Sửa code mà app không đổi | Trình duyệt cache asset cũ. | Tên file asset nên có hash (Vite làm sẵn). Nếu tự đặt tên, thêm ?v= khi đổi. |
/api/apps/… 502 thì lỗi khai backend, 401 thì hết phiên đăng nhập.14. Mã lỗi khi cài
| Mã | HTTP | Nghĩa là gì |
|---|---|---|
| no_manifest | 400 | Zip không có manifest.json ở gốc (hoặc trong đúng 1 thư mục con). |
| bad_manifest | 400 | manifest.json không phải JSON hợp lệ — thường do thừa dấu phẩy cuối. |
| invalid_manifest | 400 | Thiếu name, hoặc alias sai định dạng (phải chữ thường + gạch nối). |
| no_index_html | 400 | Thiếu public/. Nhớ build TRƯỚC khi nén. |
| bad_zip | 400 | File tải lên không phải zip. |
| too_many_files | 413 | Quá 2000 file — thường do lỡ nén cả node_modules. |
| too_large_uncompressed | 413 | Giải nén vượt 100 MB. |
| profile_incomplete | 403 | Tài khoản chưa điền đủ hồ sơ. Vào trang cá nhân bổ sung rồi cài lại. |
| unauthorized | 401 | Chưa đăng nhập, hoặc cookie phiên đã hết hạn. |
15. Checklist trước khi nộp
- ☐index.html trỏ asset bằng ./ — không còn đường dẫn tuyệt đối.
- ☐Đã grep bundle: không còn localhost, không còn khoá API.
- ☐Mở app khi chưa đăng nhập cũng không vỡ (phòng khi chạy ở host khác).
- ☐Gọi backend qua __WRITE_API_BASE__, không gọi thẳng domain riêng.
- ☐Thử ở khung hẹp 375px — nút vẫn bấm được, chữ không tràn.
- ☐Đọc được trên nền tối.
- ☐alias, name, description, icon (emoji), version đã điền đúng.
- ☐Zip không lẫn node_modules, không lẫn mã nguồn.
- ☐Cài thử lên tài khoản của mình và bấm hết các nút một lượt.
16. Giới hạn hiện tại
- Không chạy code backend trong zip. App kiểu AI Portal đóng gói sẵn
dist/embed.jschỉ chạy được phần giao diện; muốn có backend thì tách ra dịch vụ riêng rồi khaiapiProxyTarget. - Không chạy file .sql trong zip. Thư mục
schema/được chấp nhận nhưng bỏ qua — app tự lo phần lưu trữ. - Khai báo
functions[]chưa dùng được. Tizia lưu lại nhưng trợ lý AI chưa tự gọi hàm của app. - Icon Lucide chưa vẽ được. Manifest ghi
"icon": "FileText"thì Tizia hiển thị 🧩. Dùng emoji để có icon đúng ý. - Định danh người dùng ở Tizia là số, ở AI Portal là UUID. Backend nào ràng buộc định dạng UUID cần nới ra.
- Chưa có kho app chung để người khác tự cài — hiện phải nhờ quản trị công khai từng app.
17. App mẫu đang chạy
Sáu app dưới đây đang chạy thật trên Tizia bằng đúng cơ chế mô tả ở trên. Mở ra xem trước khi bắt tay làm app của bạn:
| App | Kiểu | Đáng xem vì |
|---|---|---|
| qrcode | không backend | Công cụ thuần frontend, gọn nhất để tham khảo cấu trúc. |
| annota | localStorage | Dữ liệu phức tạp mà vẫn không cần backend. |
| quantis | localStorage | App nhiều màn hình, nhiều tab. |
| bibliomap | localStorage | Đọc __PORTAL_USER__ để hiện tên người dùng. |
| library-search | có backend | Đi qua proxy /api/apps, tra cứu hơn 137.000 bản ghi. |
| convertum | có backend | App quy đổi điểm, cùng cơ chế. |
Vướng chỗ nào, gửi yêu cầu bằng nút góp ý ở góc màn hình — cùng đường với góp ý của người học, và có người đọc thật.