🌌Tizia

🛠️ 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.

1Máy bạnnpm run buildra public/2Đóng góizip manifest+ public/3Tizia nhậnkiểm tra, giải nénghi vào kho4Người học mởTizia chèn danh tínhrồi trả index.html
Vòng đời một app: bạn chỉ làm bước 1–2, Tizia lo bước 3–4.

1. Làm thử trong 5 phút

Tạo hai file này rồi nén lại thành hello.zip:

manifest.json
{
  "alias": "hello-tizia",
  "name": "Hello Tizia",
  "description": "App đầu tiên của tôi",
  "icon": "👋",
  "version": "1.0.0"
}
public/
<!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>
Terminal
zip -r hello.zip manifest.json public/

Kéo hello.zip vào trang Portal Apps:

Trang Portal Apps với vùng kéo thả file zip và danh sách app đã cài
Vùng kéo thả nằm ngay đầu trang. Cài xong, app hiện ngay ở mục “App của tôi” bên dưới.

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

App Hello Tizia đang chạy, hiển thị dòng chữ chào kèm tên và id người dùng
App chạy tại /portal-apps/u/<id-của-bạn>/hello-tizia/ — không cần cấu hình gì thêm.

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.

👋
Hello Tizia
hello-tizia.zip

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
🍅
Pomodoro Timer
pomodoro.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.zip
🃏
Flashcards Lật Thẻ
flashcards.zip

Tự 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.zip
📝
Markdown Notes
md-notes.zip

Gõ 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.zip
🎨
Color Palette
color-palette.zip

Nhậ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.zip
Cách dùng nhanh nhất
Tải hello-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
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.css

Tizia 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ạnGiá trị
File .ziptối đa 30 MB
Sau khi giải néntối đa 100 MB
Số filetối đa 2000
/portal-apps/u/23/hello-tizia/id tài khoản đã cài appalias trong manifest.json
Hai người khác nhau cài cùng một alias vẫn không đụng nhau — id tài khoản tách riêng kho chứa.

4. manifest.json

TrườngMứcÝ nghĩa
aliasbắ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.
namebắt buộcTên hiển thị trên thẻ app (tối đa 120 ký tự).
descriptionnên cóMô tả ngắn hiện dưới tên (tối đa 500 ký tự).
iconnên cóDùng EMOJI (👋, 🧮). Tên icon Lucide kiểu "FileText" vẫn nhận nhưng Tizia hiển thị 🧩 thay thế.
versionnên cóVí dụ 1.0.0. Chỉ để hiển thị; cài đè không cần tăng version.
hasBackendtuỳ chọntrue nếu app cần backend riêng. Phải khai kèm apiProxyTarget.
apiProxyTargettuỳ chọnURL TUYỆT ĐỐI tới backend của bạn (http/https). Chấp nhận tên cũ base_url.
supported_languagestuỳ chọnVí dụ ["vi","en"]. Hiện Tizia luôn báo locale vi.
type, hasFrontendOnly, functions…bỏ quaGiữ được để tương thích AI Portal; Tizia lưu nguyên manifest nhưng chưa dùng.
Chọn alias cẩn thận
Alias vừa là URL vừa là khoá định danh khi cài đè. Đổi alias = tạo ra app thứ hai, người dùng cũ vẫn ở app cũ. Đặt xong thì đừng đổi.

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ếnNộ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:

HTML Tizia trả về
<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 …
Đừng dùng __PORTAL_USER__ để phân quyền
Biến này chỉ để hiển thị. Mọi kiểm tra quyền phải làm ở backend dựa trên header 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.
Trên Tizia luôn có người dùng
Tizia bắt đăng nhập trước khi mở app, nên __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áchHợp vớiNhược điểm
localStorageNhá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.
Mẹo tách dữ liệu theo người dùng
Dùng localStorage thì thêm id vào khoá: 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.

Trình duyệtapp của bạnTiziakiểm tra đăng nhậpBackend của bạndịch vụ riêngfetch(base + '/sources')→ /api/apps/my-app/…+ X-User-Id: 23+ X-User-Email+ X-User-NameGET /sources(apiProxyTarget)JSON trả nguyên vẹn về app
App không bao giờ gọi thẳng backend — mọi thứ đi qua Tizia để được gắn danh tính.
manifest.json
{
  "alias": "my-app",
  "name": "My App",
  "hasBackend": true,
  "apiProxyTarget": "https://api.vidu.vn/my-app"
}

Phía app gọi thế nào

src/api.ts
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

Express
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ớ

  • apiProxyTarget phải là URL tuyệt đối. Đường dẫn kiểu /my-api bị 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.

① Asset phải dùng đường dẫn tương đối

App nằm trong thư mục con, nên /assets/index.js (có gạch chéo đầu) sẽ 404.

vite.config.ts
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.

② Đừng để .env lúc dev lọt vào bản build

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.

Kiểm tra trước khi nộp
grep -r "localhost" public/assets/*.js | head
③ Backend lấy từ host, đừng hard-code

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.

src/api.ts
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
Vitebase: './'
Create React App"homepage": "." trong package.json
Next.jsXuất tĩnh: output: 'export' + assetPrefix: './'
HTML thuầnChỉ 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:

index.html (chỉ khi dev)
<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.

Thử nhanh cả vòng
Cài đè mất vài giây, nên cách nhanh nhất để kiểm tra thật là: build → zip → kéo thả → mở app. Lặp lại thoải mái, app cũ tự bị thay.

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:

Trang Ứng dụng của Tizia, mục Apps cộng đồng hiển thị các thẻ app đã cài
Mỗi app là một thẻ: icon, tên, alias, mô tả trong manifest. Viết mô tả cho tử tế — người học đọc đúng dòng đó để quyết định có mở hay không.

Hai đường dẫn tới app — dùng đúng cái

Đường dẫnDù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).
Muốn app xuất hiện trong một toà nhà?
Mỗi trường có các toà nhà, trong toà nhà là các chức năng bấm được. Chức năng chỉ là một mục có 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

Terminal
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
EndpointViệc
POST /api/portal-apps/installCài hoặc cài đè app của chính bạn.
GET /api/portal-appsLiệt kê app của bạn + app đã công khai + app có sẵn.
DELETE /api/portal-apps/:idGỡ app (chủ app hoặc quản trị).
POST /api/portal-apps/:id/publishCô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ư

Người dùng Tizia phần lớn là trẻ em
Dữ liệu học tập của trẻ là dữ liệu nhạy cảm. Đừng gửi bất cứ thứ gì ra dịch vụ bên thứ ba khi không thật sự cần, và nếu có thì phải nói rõ trong mô tả app.
  • 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ứngNguyên nhân hay gặpCách sửa
Mở app ra trang trắngAsset 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ácFile .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_backendManifest chưa khai apiProxyTarget.Thêm apiProxyTarget rồi CÀI ĐÈ lại — manifest chỉ đọc lúc cài.
Backend trả 502 unsafe_targetapiProxyTarget 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ốngGhé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 đổiTrì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.
Cách soi nhanh nhất
Mở app, bật DevTools (F12) → tab Network. File nào đỏ là biết ngay hỏng ở đâu: asset 404 thì lỗi đường dẫn, /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ãHTTPNghĩa là gì
no_manifest400Zip không có manifest.json ở gốc (hoặc trong đúng 1 thư mục con).
bad_manifest400manifest.json không phải JSON hợp lệ — thường do thừa dấu phẩy cuối.
invalid_manifest400Thiếu name, hoặc alias sai định dạng (phải chữ thường + gạch nối).
no_index_html400Thiếu public/. Nhớ build TRƯỚC khi nén.
bad_zip400File tải lên không phải zip.
too_many_files413Quá 2000 file — thường do lỡ nén cả node_modules.
too_large_uncompressed413Giải nén vượt 100 MB.
profile_incomplete403Tài khoản chưa điền đủ hồ sơ. Vào trang cá nhân bổ sung rồi cài lại.
unauthorized401Chư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.js chỉ chạy được phần giao diện; muốn có backend thì tách ra dịch vụ riêng rồi khai apiProxyTarget.
  • 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:

AppKiểuĐáng xem vì
qrcodekhông backendCông cụ thuần frontend, gọn nhất để tham khảo cấu trúc.
annotalocalStorageDữ liệu phức tạp mà vẫn không cần backend.
quantislocalStorageApp nhiều màn hình, nhiều tab.
bibliomaplocalStorageĐọc __PORTAL_USER__ để hiện tên người dùng.
library-searchcó backendĐi qua proxy /api/apps, tra cứu hơn 137.000 bản ghi.
convertumcó backendApp 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.