@polardbx/pxbaas 0.0.0-stage → 0.1.0-beta.2
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 +107 -2
- package/bin/pxbaas.js +13 -0
- package/dist/app/preflight.d.ts +38 -0
- package/dist/app/preflight.d.ts.map +1 -0
- package/dist/app/preflight.js +79 -0
- package/dist/app/preflight.js.map +1 -0
- package/dist/app/spec-lint.d.ts +45 -0
- package/dist/app/spec-lint.d.ts.map +1 -0
- package/dist/app/spec-lint.js +407 -0
- package/dist/app/spec-lint.js.map +1 -0
- package/dist/app/templates.d.ts +143 -0
- package/dist/app/templates.d.ts.map +1 -0
- package/dist/app/templates.js +151 -0
- package/dist/app/templates.js.map +1 -0
- package/dist/cli/root.d.ts +3 -0
- package/dist/cli/root.d.ts.map +1 -0
- package/dist/cli/root.js +69 -0
- package/dist/cli/root.js.map +1 -0
- package/dist/commands/app/index.d.ts +8 -0
- package/dist/commands/app/index.d.ts.map +1 -0
- package/dist/commands/app/index.js +1476 -0
- package/dist/commands/app/index.js.map +1 -0
- package/dist/commands/auth/index.d.ts +3 -0
- package/dist/commands/auth/index.d.ts.map +1 -0
- package/dist/commands/auth/index.js +198 -0
- package/dist/commands/auth/index.js.map +1 -0
- package/dist/commands/completion/index.d.ts +3 -0
- package/dist/commands/completion/index.d.ts.map +1 -0
- package/dist/commands/completion/index.js +10 -0
- package/dist/commands/completion/index.js.map +1 -0
- package/dist/commands/data/index.d.ts +3 -0
- package/dist/commands/data/index.d.ts.map +1 -0
- package/dist/commands/data/index.js +245 -0
- package/dist/commands/data/index.js.map +1 -0
- package/dist/commands/db/index.d.ts +15 -0
- package/dist/commands/db/index.d.ts.map +1 -0
- package/dist/commands/db/index.js +389 -0
- package/dist/commands/db/index.js.map +1 -0
- package/dist/commands/doctor/index.d.ts +3 -0
- package/dist/commands/doctor/index.d.ts.map +1 -0
- package/dist/commands/doctor/index.js +305 -0
- package/dist/commands/doctor/index.js.map +1 -0
- package/dist/commands/functions/delete.d.ts +3 -0
- package/dist/commands/functions/delete.d.ts.map +1 -0
- package/dist/commands/functions/delete.js +55 -0
- package/dist/commands/functions/delete.js.map +1 -0
- package/dist/commands/functions/deploy.d.ts +3 -0
- package/dist/commands/functions/deploy.d.ts.map +1 -0
- package/dist/commands/functions/deploy.js +118 -0
- package/dist/commands/functions/deploy.js.map +1 -0
- package/dist/commands/functions/index.d.ts +3 -0
- package/dist/commands/functions/index.d.ts.map +1 -0
- package/dist/commands/functions/index.js +15 -0
- package/dist/commands/functions/index.js.map +1 -0
- package/dist/commands/functions/list.d.ts +6 -0
- package/dist/commands/functions/list.d.ts.map +1 -0
- package/dist/commands/functions/list.js +63 -0
- package/dist/commands/functions/list.js.map +1 -0
- package/dist/commands/functions/new.d.ts +3 -0
- package/dist/commands/functions/new.d.ts.map +1 -0
- package/dist/commands/functions/new.js +57 -0
- package/dist/commands/functions/new.js.map +1 -0
- package/dist/commands/gen/index.d.ts +3 -0
- package/dist/commands/gen/index.d.ts.map +1 -0
- package/dist/commands/gen/index.js +320 -0
- package/dist/commands/gen/index.js.map +1 -0
- package/dist/commands/instance/index.d.ts +8 -0
- package/dist/commands/instance/index.d.ts.map +1 -0
- package/dist/commands/instance/index.js +361 -0
- package/dist/commands/instance/index.js.map +1 -0
- package/dist/commands/login/index.d.ts +3 -0
- package/dist/commands/login/index.d.ts.map +1 -0
- package/dist/commands/login/index.js +160 -0
- package/dist/commands/login/index.js.map +1 -0
- package/dist/commands/login/login.d.ts +3 -0
- package/dist/commands/login/login.d.ts.map +1 -0
- package/dist/commands/login/login.js +185 -0
- package/dist/commands/login/login.js.map +1 -0
- package/dist/commands/logout/index.d.ts +3 -0
- package/dist/commands/logout/index.d.ts.map +1 -0
- package/dist/commands/logout/index.js +41 -0
- package/dist/commands/logout/index.js.map +1 -0
- package/dist/commands/profile/import-env.d.ts +13 -0
- package/dist/commands/profile/import-env.d.ts.map +1 -0
- package/dist/commands/profile/import-env.js +81 -0
- package/dist/commands/profile/import-env.js.map +1 -0
- package/dist/commands/profile/index.d.ts +3 -0
- package/dist/commands/profile/index.d.ts.map +1 -0
- package/dist/commands/profile/index.js +112 -0
- package/dist/commands/profile/index.js.map +1 -0
- package/dist/commands/rest/index.d.ts +3 -0
- package/dist/commands/rest/index.d.ts.map +1 -0
- package/dist/commands/rest/index.js +102 -0
- package/dist/commands/rest/index.js.map +1 -0
- package/dist/commands/skill/index.d.ts +11 -0
- package/dist/commands/skill/index.d.ts.map +1 -0
- package/dist/commands/skill/index.js +153 -0
- package/dist/commands/skill/index.js.map +1 -0
- package/dist/commands/status/index.d.ts +3 -0
- package/dist/commands/status/index.d.ts.map +1 -0
- package/dist/commands/status/index.js +59 -0
- package/dist/commands/status/index.js.map +1 -0
- package/dist/commands/storage/index.d.ts +3 -0
- package/dist/commands/storage/index.d.ts.map +1 -0
- package/dist/commands/storage/index.js +198 -0
- package/dist/commands/storage/index.js.map +1 -0
- package/dist/commands/whoami/index.d.ts +3 -0
- package/dist/commands/whoami/index.d.ts.map +1 -0
- package/dist/commands/whoami/index.js +82 -0
- package/dist/commands/whoami/index.js.map +1 -0
- package/dist/credentials/commit.d.ts +38 -0
- package/dist/credentials/commit.d.ts.map +1 -0
- package/dist/credentials/commit.js +57 -0
- package/dist/credentials/commit.js.map +1 -0
- package/dist/credentials/credential-store.d.ts +57 -0
- package/dist/credentials/credential-store.d.ts.map +1 -0
- package/dist/credentials/credential-store.js +180 -0
- package/dist/credentials/credential-store.js.map +1 -0
- package/dist/credentials/env-file.d.ts +44 -0
- package/dist/credentials/env-file.d.ts.map +1 -0
- package/dist/credentials/env-file.js +118 -0
- package/dist/credentials/env-file.js.map +1 -0
- package/dist/credentials/formatter.d.ts +39 -0
- package/dist/credentials/formatter.d.ts.map +1 -0
- package/dist/credentials/formatter.js +79 -0
- package/dist/credentials/formatter.js.map +1 -0
- package/dist/credentials/index.d.ts +13 -0
- package/dist/credentials/index.d.ts.map +1 -0
- package/dist/credentials/index.js +13 -0
- package/dist/credentials/index.js.map +1 -0
- package/dist/credentials/jwt-introspect.d.ts +32 -0
- package/dist/credentials/jwt-introspect.d.ts.map +1 -0
- package/dist/credentials/jwt-introspect.js +64 -0
- package/dist/credentials/jwt-introspect.js.map +1 -0
- package/dist/credentials/masker.d.ts +20 -0
- package/dist/credentials/masker.d.ts.map +1 -0
- package/dist/credentials/masker.js +37 -0
- package/dist/credentials/masker.js.map +1 -0
- package/dist/credentials/profile.d.ts +83 -0
- package/dist/credentials/profile.d.ts.map +1 -0
- package/dist/credentials/profile.js +184 -0
- package/dist/credentials/profile.js.map +1 -0
- package/dist/credentials/tenant-guard.d.ts +16 -0
- package/dist/credentials/tenant-guard.d.ts.map +1 -0
- package/dist/credentials/tenant-guard.js +53 -0
- package/dist/credentials/tenant-guard.js.map +1 -0
- package/dist/credentials/tty-guard.d.ts +19 -0
- package/dist/credentials/tty-guard.d.ts.map +1 -0
- package/dist/credentials/tty-guard.js +47 -0
- package/dist/credentials/tty-guard.js.map +1 -0
- package/dist/db/apply-schema.d.ts +144 -0
- package/dist/db/apply-schema.d.ts.map +1 -0
- package/dist/db/apply-schema.js +359 -0
- package/dist/db/apply-schema.js.map +1 -0
- package/dist/db/sql-file.d.ts +104 -0
- package/dist/db/sql-file.d.ts.map +1 -0
- package/dist/db/sql-file.js +584 -0
- package/dist/db/sql-file.js.map +1 -0
- package/dist/instance/provision.d.ts +89 -0
- package/dist/instance/provision.d.ts.map +1 -0
- package/dist/instance/provision.js +139 -0
- package/dist/instance/provision.js.map +1 -0
- package/dist/instance/zero-store.d.ts +47 -0
- package/dist/instance/zero-store.d.ts.map +1 -0
- package/dist/instance/zero-store.js +91 -0
- package/dist/instance/zero-store.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +46 -0
- package/dist/main.js.map +1 -0
- package/dist/skill/assets.d.ts +38 -0
- package/dist/skill/assets.d.ts.map +1 -0
- package/dist/skill/assets.js +111 -0
- package/dist/skill/assets.js.map +1 -0
- package/dist/skill/hint.d.ts +39 -0
- package/dist/skill/hint.d.ts.map +1 -0
- package/dist/skill/hint.js +109 -0
- package/dist/skill/hint.js.map +1 -0
- package/dist/skill/hosts.d.ts +21 -0
- package/dist/skill/hosts.d.ts.map +1 -0
- package/dist/skill/hosts.js +38 -0
- package/dist/skill/hosts.js.map +1 -0
- package/dist/skill/installer.d.ts +88 -0
- package/dist/skill/installer.d.ts.map +1 -0
- package/dist/skill/installer.js +203 -0
- package/dist/skill/installer.js.map +1 -0
- package/dist/skill/ownership.d.ts +35 -0
- package/dist/skill/ownership.d.ts.map +1 -0
- package/dist/skill/ownership.js +52 -0
- package/dist/skill/ownership.js.map +1 -0
- package/package.json +63 -3
- package/skills/pxbaas-app-spec/SKILL.md +163 -0
- package/skills/pxbaas-app-spec/references/deploy-options.md +157 -0
- package/skills/pxbaas-app-spec/references/guardrails-pitfalls.md +89 -0
- package/skills/pxbaas-app-spec/references/runtime-contract.md +151 -0
- package/skills/pxbaas-app-spec/references/ssr-session.md +121 -0
- package/skills/pxbaas-app-spec/references/templates-examples.md +311 -0
- package/skills/pxbaas-cli/SKILL.md +171 -0
- package/skills/pxbaas-cli/references/api-reference.md +68 -0
- package/skills/pxbaas-cli/references/app-deploy.md +170 -0
- package/skills/pxbaas-cli/references/auth-and-credentials.md +100 -0
- package/skills/pxbaas-cli/references/data-postgrest.md +85 -0
- package/skills/pxbaas-cli/references/db-and-meta.md +97 -0
- package/skills/pxbaas-cli/references/edge-functions.md +71 -0
- package/skills/pxbaas-cli/references/multi-tenant-apps.md +138 -0
- package/skills/pxbaas-cli/references/provision.md +86 -0
- package/skills/pxbaas-cli/references/safety-and-two-phase.md +49 -0
- package/skills/pxbaas-cli/references/storage.md +61 -0
- package/skills/pxbaas-cli/references/troubleshooting.md +73 -0
- package/templates/webapp/api/index.ts +55 -0
- package/templates/webapp/csr-bff/README.md +81 -0
- package/templates/webapp/csr-bff/backend/index.ts +168 -0
- package/templates/webapp/csr-bff/db/rls.json +13 -0
- package/templates/webapp/csr-bff/db/schema.sql +49 -0
- package/templates/webapp/csr-bff/frontend/dist/admin.html +101 -0
- package/templates/webapp/csr-bff/frontend/dist/article.html +44 -0
- package/templates/webapp/csr-bff/frontend/dist/blog.js +206 -0
- package/templates/webapp/csr-bff/frontend/dist/blogger.html +53 -0
- package/templates/webapp/csr-bff/frontend/dist/index.html +70 -0
- package/templates/webapp/csr-bff/frontend/dist/login.html +56 -0
- package/templates/webapp/csr-bff/frontend/dist/profile.html +59 -0
- package/templates/webapp/csr-bff/frontend/dist/signup.html +78 -0
- package/templates/webapp/csr-bff/frontend/dist/style.css +27 -0
- package/templates/webapp/manifest.json +299 -0
- package/templates/webapp/mpa/about.html +29 -0
- package/templates/webapp/mpa/app.js +42 -0
- package/templates/webapp/mpa/index.html +42 -0
- package/templates/webapp/mpa/index.ts +60 -0
- package/templates/webapp/mpa/style.css +42 -0
- package/templates/webapp/react-vite/frontend/index.html +12 -0
- package/templates/webapp/react-vite/frontend/package-lock.json +1826 -0
- package/templates/webapp/react-vite/frontend/package.json +23 -0
- package/templates/webapp/react-vite/frontend/src/App.tsx +34 -0
- package/templates/webapp/react-vite/frontend/src/main.tsx +13 -0
- package/templates/webapp/react-vite/frontend/src/style.css +46 -0
- package/templates/webapp/react-vite/frontend/tsconfig.json +20 -0
- package/templates/webapp/react-vite/frontend/vite.config.ts +10 -0
- package/templates/webapp/react-vite-hono/backend/index.ts +21 -0
- package/templates/webapp/react-vite-hono/frontend/index.html +12 -0
- package/templates/webapp/react-vite-hono/frontend/package-lock.json +1826 -0
- package/templates/webapp/react-vite-hono/frontend/package.json +23 -0
- package/templates/webapp/react-vite-hono/frontend/src/App.tsx +47 -0
- package/templates/webapp/react-vite-hono/frontend/src/main.tsx +13 -0
- package/templates/webapp/react-vite-hono/frontend/src/style.css +66 -0
- package/templates/webapp/react-vite-hono/frontend/tsconfig.json +20 -0
- package/templates/webapp/react-vite-hono/frontend/vite.config.ts +10 -0
- package/templates/webapp/spa/index.ts +119 -0
- package/templates/webapp/ssr-auth/backend/index.ts +133 -0
- package/templates/webapp/ssr-auth/frontend/dist/index.html +15 -0
- package/templates/webapp/ssr-blog/README.md +111 -0
- package/templates/webapp/ssr-blog/backend/index.ts +764 -0
- package/templates/webapp/ssr-blog/db/rls.json +13 -0
- package/templates/webapp/ssr-blog/db/schema.sql +51 -0
- package/templates/webapp/ssr-blog/frontend/dist/index.html +39 -0
- package/templates/webapp/ssr-frameworks/README.md +64 -0
- package/templates/webapp/ssr-frameworks/backend/index.ts +241 -0
- package/templates/webapp/ssr-frameworks/frontend/dist/index.html +31 -0
- package/templates/webapp/ssr-hono/backend/index.ts +92 -0
- package/templates/webapp/ssr-hono/frontend/dist/index.html +12 -0
- package/templates/webapp/ssr-shop/README.md +80 -0
- package/templates/webapp/ssr-shop/backend/index.ts +491 -0
- package/templates/webapp/ssr-shop/db/rls.json +16 -0
- package/templates/webapp/ssr-shop/db/schema.sql +90 -0
- package/templates/webapp/ssr-shop/frontend/dist/index.html +12 -0
- package/templates/webapp/vanilla-static/frontend/app.js +6 -0
- package/templates/webapp/vanilla-static/frontend/index.html +19 -0
- package/templates/webapp/vanilla-static/frontend/style.css +43 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
// Shop Demo v2 — React SSR 电商 webapp v5(买家注册/登录 + 下单门禁 + 固定密码管理员)
|
|
2
|
+
//
|
|
3
|
+
// 框架: React 18 renderToString + Hono 4.6.12(平台锁版本缓存)
|
|
4
|
+
// 形态: 目录/详情/搜索 = 全量 SSR(服务端经 DX-12 派生内网基址取 MySQL);
|
|
5
|
+
// 购物车/结算/订单/注册/登录/管理 = SSR shell + 浏览器 hydrate;
|
|
6
|
+
// 鉴权: 浏览器直连 GoTrue(/apps 入口按平台契约剥离用户凭据, 会话 SSR 不成立, af565f8b);
|
|
7
|
+
// 下单门禁在 RLS 数据层: orders/order_items 仅 authenticated 且 buyer_email={{jwt.email}};
|
|
8
|
+
// 管理员固定账号 admin@shop.demo / admin-2026, RLS 邮箱白名单看全量订单。
|
|
9
|
+
// 契约: 单文件自包含(D16) · marker data-ssr="v1"(DX-2) · 双形态路由(D20) · style 对象 ·
|
|
10
|
+
// 客户端 JS 禁反引号/${} · innerHTML 插值过 esc() · anon/public 兜底常量(D18 家族)。
|
|
11
|
+
|
|
12
|
+
import { Hono } from "npm:hono@4.6.12";
|
|
13
|
+
import React from "npm:react@18.3.1";
|
|
14
|
+
import { renderToString } from "npm:react-dom@18.3.1/server";
|
|
15
|
+
|
|
16
|
+
const h = React.createElement;
|
|
17
|
+
|
|
18
|
+
// ─── DEPLOY CONFIG(平台注入优先;缺失则 worker 明确返回 503,绝不回落到任何真实实例)───
|
|
19
|
+
// env 契约(MR #30497903 起):平台按三键白名单向 App candidate 注入 SUPABASE_INTERNAL_URL /
|
|
20
|
+
// SUPABASE_ANON_KEY / SUPABASE_URL,spec-lint 的 deno-env-not-allowed 放行这三键。本模板直读
|
|
21
|
+
// SUPABASE_INTERNAL_URL(数据面唯一可达基址)与 SUPABASE_ANON_KEY(数据面凭据),无需手填明文 key。
|
|
22
|
+
// 若目标实例 sandbox 镜像尚未含该 MR,两值为空 → configError 明确 503,绝不静默连到别的实例/租户。
|
|
23
|
+
// INTERNAL_BASE —— 平台注入的内网基址(把公网 host 的 http./https. 中缀去掉),沙箱内可达;
|
|
24
|
+
// 公网 VIP 因 egress 过滤不可达(非 hairpin),切勿改填公网地址。
|
|
25
|
+
// ANON_KEY —— 单租户=实例 anon key;多租户=**该租户** anon key(自带 tenant_id claim,
|
|
26
|
+
// REST/Auth 据此路由,无需 X-Tenant-Id)。
|
|
27
|
+
// anon key 是公开值(随页面下发给每个浏览器),不是机密;service_role key 严禁出现在 worker 里。
|
|
28
|
+
const INTERNAL_BASE = Deno.env.get("SUPABASE_INTERNAL_URL") ?? "";
|
|
29
|
+
const ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? "";
|
|
30
|
+
|
|
31
|
+
function configError(): string | null {
|
|
32
|
+
if (!INTERNAL_BASE) return "SUPABASE_INTERNAL_URL 未注入:目标实例 sandbox 镜像早于 MR #30497903,需平台注入内网基址后本 SSR 才能取数。";
|
|
33
|
+
if (!ANON_KEY) return "SUPABASE_ANON_KEY 未注入:目标实例 sandbox 镜像早于 MR #30497903,需平台注入 anon key。";
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
function anonKey(): string {
|
|
37
|
+
return ANON_KEY;
|
|
38
|
+
}
|
|
39
|
+
// 服务端取数唯一合法通道:内网基址(见上方 DEPLOY CONFIG)。
|
|
40
|
+
function internalBase(): string {
|
|
41
|
+
return INTERNAL_BASE.replace(/\/+$/, "");
|
|
42
|
+
}
|
|
43
|
+
function configErrorDoc(msg: string): string {
|
|
44
|
+
return `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
|
|
45
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"><title>部署配置缺失</title>
|
|
46
|
+
<style>body{margin:0;padding:48px 20px;font:15px/1.7 system-ui,-apple-system,sans-serif;background:#0b1020;color:#e8eaf2}
|
|
47
|
+
.card{max-width:660px;margin:0 auto;padding:24px 26px;border-radius:14px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.12)}
|
|
48
|
+
code{background:rgba(0,0,0,.35);padding:2px 6px;border-radius:6px}</style></head>
|
|
49
|
+
<body><div class="card" id="__ssr" data-ssr="v1"><h1 style="margin:0 0 8px;font-size:20px">部署配置缺失</h1>
|
|
50
|
+
<p style="color:#9aa3b8;margin:0 0 10px">${msg}</p>
|
|
51
|
+
<p style="color:#9aa3b8;margin:0">请在 <code>backend/index.ts</code> 顶部的 DEPLOY CONFIG 常量块填入目标实例的内网基址与 anon key,然后重新部署。</p>
|
|
52
|
+
</div></body></html>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Product {
|
|
56
|
+
id: number;
|
|
57
|
+
name: string;
|
|
58
|
+
description: string;
|
|
59
|
+
price_cents: number;
|
|
60
|
+
stock: number;
|
|
61
|
+
emoji: string;
|
|
62
|
+
category: string;
|
|
63
|
+
created_at?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function fetchProducts(): Promise<Product[]> {
|
|
67
|
+
try {
|
|
68
|
+
const r = await fetch(internalBase() + "/rest/v1/products?select=id,name,description,price_cents,stock,emoji,category&order=id.asc", {
|
|
69
|
+
headers: { apikey: anonKey() },
|
|
70
|
+
});
|
|
71
|
+
if (!r.ok) return [];
|
|
72
|
+
return (await r.json()) as Product[];
|
|
73
|
+
} catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function fetchProduct(id: string): Promise<Product | null> {
|
|
78
|
+
try {
|
|
79
|
+
const r = await fetch(internalBase() + "/rest/v1/products?select=*&id=eq." + encodeURIComponent(id), {
|
|
80
|
+
headers: { apikey: anonKey() },
|
|
81
|
+
});
|
|
82
|
+
if (!r.ok) return null;
|
|
83
|
+
const rows = (await r.json()) as Product[];
|
|
84
|
+
return rows[0] || null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function money(c: number): string {
|
|
91
|
+
return "¥" + (Number(c) / 100).toFixed(2);
|
|
92
|
+
}
|
|
93
|
+
const HOT_WORDS = ["键盘", "耳机", "音箱", "手表", "充电", "收纳", "路由", "NAS", "显示器", "电竞椅"];
|
|
94
|
+
|
|
95
|
+
const CSS = `
|
|
96
|
+
:root { color-scheme: dark;
|
|
97
|
+
--bg1:#0b1020; --bg2:#171338; --card:rgba(255,255,255,.045); --line:rgba(255,255,255,.10);
|
|
98
|
+
--txt:#e8eaf2; --muted:#9aa3b8; --acc1:#6366f1; --acc2:#ec4899; --gold:#f6c453; }
|
|
99
|
+
* { box-sizing: border-box; }
|
|
100
|
+
html,body { margin:0; }
|
|
101
|
+
body { font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif; color:var(--txt);
|
|
102
|
+
min-height:100vh; padding:0 18px 60px; max-width:1040px; margin:0 auto;
|
|
103
|
+
background:
|
|
104
|
+
radial-gradient(1100px 520px at 85% -10%, rgba(99,102,241,.30), transparent 60%),
|
|
105
|
+
radial-gradient(900px 480px at 8% 108%, rgba(236,72,153,.20), transparent 60%),
|
|
106
|
+
linear-gradient(160deg, var(--bg1), var(--bg2) 55%, var(--bg1));
|
|
107
|
+
background-attachment: fixed; }
|
|
108
|
+
header { position:sticky; top:0; z-index:20; display:flex; align-items:center; gap:18px;
|
|
109
|
+
padding:12px 4px; backdrop-filter: blur(14px);
|
|
110
|
+
background: rgba(11,16,32,.55); border-bottom:1px solid var(--line); }
|
|
111
|
+
.logo { font-size:21px; font-weight:800;
|
|
112
|
+
background: linear-gradient(90deg, var(--acc1), var(--acc2)); -webkit-background-clip:text; background-clip:text; color:transparent; }
|
|
113
|
+
nav { margin-left:auto; }
|
|
114
|
+
nav a { color:#aeb9d6; text-decoration:none; margin-left:16px; font-size:14px; }
|
|
115
|
+
nav a:hover { color:#fff; }
|
|
116
|
+
.badge { background:linear-gradient(90deg,var(--acc1),var(--acc2)); border-radius:999px; padding:1px 8px; font-size:12px; color:#fff; }
|
|
117
|
+
.muted { color:var(--muted); font-size:13px; }
|
|
118
|
+
.topbar { margin:16px 0 4px; }
|
|
119
|
+
.searchbar { display:flex; align-items:stretch; border:2px solid var(--acc1); border-radius:999px;
|
|
120
|
+
overflow:hidden; background:rgba(255,255,255,.06); }
|
|
121
|
+
.searchbar select { background:transparent; color:var(--txt); border:0; outline:none; font:inherit;
|
|
122
|
+
padding:0 14px; border-right:1px solid var(--line); cursor:pointer; }
|
|
123
|
+
.searchbar select option { color:#111; }
|
|
124
|
+
.searchbar .q { flex:1; background:transparent; border:0; outline:none; color:var(--txt);
|
|
125
|
+
padding:13px 18px; font:inherit; margin:0; }
|
|
126
|
+
.searchbar .q::placeholder { color:var(--muted); }
|
|
127
|
+
.searchbar .sbtn { border:0; cursor:pointer; color:#fff; font-size:15px; font-weight:600;
|
|
128
|
+
padding:0 34px; background:linear-gradient(90deg,var(--acc1),var(--acc2)); }
|
|
129
|
+
.hotwords { display:flex; gap:16px; flex-wrap:wrap; margin:8px 4px 0; font-size:13px; }
|
|
130
|
+
.hotwords a { color:#ff8fa3; text-decoration:none; cursor:pointer; }
|
|
131
|
+
.hotwords a:hover { color:#fff; text-decoration:underline; }
|
|
132
|
+
.hero { margin:14px 0 6px; padding:24px 22px; border-radius:18px; border:1px solid var(--line);
|
|
133
|
+
background: linear-gradient(120deg, rgba(99,102,241,.22), rgba(236,72,153,.14)); }
|
|
134
|
+
.hero h2 { margin:0 0 6px; font-size:24px; }
|
|
135
|
+
.toolbar { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin:12px 0 4px; }
|
|
136
|
+
.chip { border:1px solid var(--line); background:var(--card); color:var(--muted); border-radius:999px;
|
|
137
|
+
padding:6px 14px; font-size:13px; text-decoration:none; }
|
|
138
|
+
.chip.on { color:#fff; border-color:transparent; background:linear-gradient(90deg,var(--acc1),var(--acc2)); }
|
|
139
|
+
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(210px,1fr)); gap:16px; margin-top:16px; }
|
|
140
|
+
.card { background:var(--card); border:1px solid var(--line); border-radius:16px; padding:16px;
|
|
141
|
+
backdrop-filter: blur(8px); transition: transform .18s ease, box-shadow .18s ease; }
|
|
142
|
+
.card:hover { transform: translateY(-4px); box-shadow: 0 12px 30px rgba(0,0,0,.35); }
|
|
143
|
+
.thumb { font-size:46px; text-align:center; padding:16px 0; border-radius:14px;
|
|
144
|
+
background: radial-gradient(circle at 50% 40%, rgba(99,102,241,.28), rgba(255,255,255,.03) 70%); }
|
|
145
|
+
.tag { display:inline-block; font-size:11px; color:#c7d2fe; background:rgba(99,102,241,.18);
|
|
146
|
+
border:1px solid rgba(99,102,241,.4); border-radius:999px; padding:2px 9px; margin-bottom:6px; }
|
|
147
|
+
.pname { font-size:15px; font-weight:600; color:#eef1ff; text-decoration:none; }
|
|
148
|
+
.pname:hover { color:#fff; text-decoration:underline; }
|
|
149
|
+
.price { color:var(--gold); font-weight:700; }
|
|
150
|
+
button, a.btn { border:0; border-radius:10px; padding:9px 16px; font:inherit; cursor:pointer; color:#fff;
|
|
151
|
+
background:linear-gradient(90deg,var(--acc1),var(--acc2)); text-decoration:none; display:inline-block; }
|
|
152
|
+
button.ghost, a.btn.ghost { background:rgba(255,255,255,.10); }
|
|
153
|
+
button.danger { background:linear-gradient(90deg,#ef4444,#b3261e); }
|
|
154
|
+
button.mini { padding:4px 10px; border-radius:8px; }
|
|
155
|
+
input { width:100%; background:var(--card); color:var(--txt); border:1px solid var(--line);
|
|
156
|
+
border-radius:10px; padding:10px 12px; font:inherit; margin:6px 0; outline:none; }
|
|
157
|
+
table { width:100%; border-collapse:collapse; margin-top:10px; }
|
|
158
|
+
td, th { padding:10px 8px; border-bottom:1px solid var(--line); text-align:left; font-size:14px; }
|
|
159
|
+
th { color:var(--muted); font-weight:600; }
|
|
160
|
+
.row { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
|
161
|
+
footer { margin-top:40px; padding-top:16px; border-top:1px solid var(--line); color:var(--muted); font-size:12px; text-align:center; }
|
|
162
|
+
.empty { padding:40px 0; text-align:center; color:var(--muted); }
|
|
163
|
+
.authbox { max-width:400px; margin:24px auto; }
|
|
164
|
+
.toast { position:fixed; left:50%; bottom:36px; transform:translateX(-50%) translateY(20px); z-index:60;
|
|
165
|
+
background:linear-gradient(90deg,var(--acc1),var(--acc2)); color:#fff; border-radius:999px;
|
|
166
|
+
padding:10px 26px; font-size:14px; font-weight:600; opacity:0; pointer-events:none;
|
|
167
|
+
transition: opacity .18s ease, transform .18s ease; box-shadow:0 10px 30px rgba(0,0,0,.4); }
|
|
168
|
+
.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
|
|
169
|
+
.badge.pulse { animation:bp .5s ease; }
|
|
170
|
+
@keyframes bp { 0%{transform:scale(1)} 40%{transform:scale(1.5)} 100%{transform:scale(1)} }
|
|
171
|
+
`;
|
|
172
|
+
|
|
173
|
+
// ---------- React 组件(服务端渲染) ----------
|
|
174
|
+
// base: 相对链接前缀(/product/:id 为二级路径须用 ../,其余 ./)
|
|
175
|
+
function Header(props: { base: string }) {
|
|
176
|
+
const b = props.base;
|
|
177
|
+
return h("header", null,
|
|
178
|
+
h("span", { className: "logo" }, "🛍️ Supabase Shop · React SSR"),
|
|
179
|
+
h("nav", null,
|
|
180
|
+
h("a", { href: b + "shop" }, "商品"),
|
|
181
|
+
h("a", { href: b + "cart" }, "购物车 ", h("span", { className: "badge", id: "cartcount" }, "0")),
|
|
182
|
+
h("a", { href: b + "orders" }, "我的订单"),
|
|
183
|
+
h("span", { id: "autharea", className: "muted" })));
|
|
184
|
+
}
|
|
185
|
+
function SearchBar(props: { q: string; cat: string; cats: string[] }) {
|
|
186
|
+
return h("div", { className: "topbar" },
|
|
187
|
+
h("form", { className: "searchbar", method: "GET", action: "./shop" },
|
|
188
|
+
h("select", { name: "cat" },
|
|
189
|
+
props.cats.map((c) => h("option", { value: c, selected: c === props.cat }, c === "all" ? "全部" : c))),
|
|
190
|
+
h("input", { className: "q", name: "q", placeholder: "搜索商品名 / 描述(服务端渲染结果)…", value: props.q }),
|
|
191
|
+
h("button", { className: "sbtn", type: "submit" }, "搜索")),
|
|
192
|
+
h("div", { className: "hotwords" },
|
|
193
|
+
HOT_WORDS.map((w) => h("a", { href: "./shop?q=" + encodeURIComponent(w) }, w))));
|
|
194
|
+
}
|
|
195
|
+
function ProductCard(p: Product) {
|
|
196
|
+
return h("div", { className: "card" },
|
|
197
|
+
h("div", { className: "thumb" }, p.emoji || "📦"),
|
|
198
|
+
p.category ? h("span", { className: "tag" }, p.category) : null,
|
|
199
|
+
h("div", null, h("a", { className: "pname", href: "./product/" + p.id }, p.name)),
|
|
200
|
+
h("div", { className: "muted", style: { margin: "4px 0 8px" } }, p.description || ""),
|
|
201
|
+
h("div", { className: "row", style: { justifyContent: "space-between" } },
|
|
202
|
+
h("span", { className: "price" }, money(p.price_cents)),
|
|
203
|
+
h("button", { className: "mini", type: "button", "data-add": String(p.id) }, "加购")),
|
|
204
|
+
h("div", { className: "muted", style: { marginTop: "6px" } }, "库存 " + p.stock));
|
|
205
|
+
}
|
|
206
|
+
function CatalogView(props: { products: Product[]; q: string; cat: string; cats: string[]; note: string }) {
|
|
207
|
+
const list = props.products.filter((p) => {
|
|
208
|
+
const okCat = props.cat === "all" || p.category === props.cat;
|
|
209
|
+
const okQ = !props.q || (p.name || "").indexOf(props.q) >= 0 || (p.description || "").indexOf(props.q) >= 0;
|
|
210
|
+
return okCat && okQ;
|
|
211
|
+
});
|
|
212
|
+
return h(React.Fragment, null,
|
|
213
|
+
h("div", { className: "hero" },
|
|
214
|
+
h("h2", null, "发现好物 · 服务端渲染直达"),
|
|
215
|
+
h("div", { className: "muted" }, "共 " + props.products.length + " 件商品 · React renderToString · 数据经内网基址服务端取自 MySQL · " + props.note)),
|
|
216
|
+
h("div", { className: "toolbar" },
|
|
217
|
+
props.cats.map((c) => h("a", {
|
|
218
|
+
className: "chip" + (c === props.cat ? " on" : ""),
|
|
219
|
+
href: "./shop?cat=" + encodeURIComponent(c) + (props.q ? "&q=" + encodeURIComponent(props.q) : ""),
|
|
220
|
+
}, c === "all" ? "全部" : c)),
|
|
221
|
+
props.q ? h("span", { className: "muted" }, "搜索:“" + props.q + "” · " + list.length + " 件") : null),
|
|
222
|
+
list.length
|
|
223
|
+
? h("div", { className: "grid" }, list.map((p) => h(ProductCard, { key: p.id, ...p })))
|
|
224
|
+
: h("div", { className: "empty" }, "没有匹配的商品"));
|
|
225
|
+
}
|
|
226
|
+
function ProductView(props: { p: Product | null; base: string }) {
|
|
227
|
+
const p = props.p;
|
|
228
|
+
if (!p) return h("div", { className: "empty" }, "商品不存在");
|
|
229
|
+
return h("div", { className: "card", style: { maxWidth: "520px", margin: "20px auto" } },
|
|
230
|
+
h("div", { className: "thumb" }, p.emoji || "📦"),
|
|
231
|
+
p.category ? h("span", { className: "tag" }, p.category) : null,
|
|
232
|
+
h("h2", { style: { margin: "6px 0" } }, p.name),
|
|
233
|
+
h("p", { className: "muted" }, p.description || ""),
|
|
234
|
+
h("div", { className: "price", style: { fontSize: "24px" } }, money(p.price_cents)),
|
|
235
|
+
h("div", { className: "muted" }, "库存 " + p.stock + " · 上架 " + String(p.created_at || "")),
|
|
236
|
+
h("div", { className: "row", style: { marginTop: "14px" } },
|
|
237
|
+
h("button", { type: "button", "data-add": String(p.id) }, "加入购物车"),
|
|
238
|
+
h("a", { className: "btn ghost", href: props.base + "shop" }, "返回")));
|
|
239
|
+
}
|
|
240
|
+
function ClientShell(props: { title: string; hint: string }) {
|
|
241
|
+
return h("div", { className: "card authbox", style: { maxWidth: "640px", margin: "20px auto" } },
|
|
242
|
+
h("h2", { style: { marginTop: 0 } }, props.title),
|
|
243
|
+
h("div", { className: "muted" }, props.hint),
|
|
244
|
+
h("div", { id: "client-root" }, h("div", { className: "empty" }, "hydrate 加载中…")));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------- 客户端脚本(禁反引号/${};innerHTML 插值过 esc) ----------
|
|
248
|
+
function clientScript(anon: string): string {
|
|
249
|
+
const js = `
|
|
250
|
+
var ANON = "__ANON__";
|
|
251
|
+
var REST = location.origin + '/rest/v1';
|
|
252
|
+
var AUTH = location.origin + '/auth/v1';
|
|
253
|
+
var CART_KEY = 'shop_cart_v2';
|
|
254
|
+
var AUTH_KEY = 'shop_auth_v1';
|
|
255
|
+
var ADMIN_EMAIL = 'admin@shop.demo';
|
|
256
|
+
function el(id){return document.getElementById(id);}
|
|
257
|
+
function esc(s){return String(s).replace(/[&<>"']/g,function(c){return{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
|
|
258
|
+
function money(c){return '¥'+(Number(c)/100).toFixed(2);}
|
|
259
|
+
function hdrs(){return {'apikey':ANON,'Authorization':'Bearer '+ANON};}
|
|
260
|
+
function getJSON(u,h){return fetch(u,{headers:h||hdrs()}).then(function(r){return r.json();});}
|
|
261
|
+
function postJSON(u,b,h){return fetch(u,{method:'POST',headers:Object.assign(h||hdrs(),{'Content-Type':'application/json','Prefer':'return=representation'}),body:JSON.stringify(b)}).then(function(r){return r.json().then(function(j){return {ok:r.ok,status:r.status,body:j};});});}
|
|
262
|
+
function getCart(){try{return JSON.parse(localStorage.getItem(CART_KEY)||'{}');}catch(e){return {};}}
|
|
263
|
+
function setCart(c){localStorage.setItem(CART_KEY,JSON.stringify(c));refreshBadge();}
|
|
264
|
+
function cartCount(){var c=getCart(),n=0;for(var k in c)n+=c[k];return n;}
|
|
265
|
+
function refreshBadge(){var b=el('cartcount');if(b)b.textContent=cartCount();}
|
|
266
|
+
var toastTimer=null;
|
|
267
|
+
function toast(msg){var t=el('shoptoast');if(!t){t=document.createElement('div');t.id='shoptoast';t.className='toast';document.body.appendChild(t);}
|
|
268
|
+
t.textContent=msg;t.classList.add('show');if(toastTimer)clearTimeout(toastTimer);toastTimer=setTimeout(function(){t.classList.remove('show');},1200);}
|
|
269
|
+
function addToCart(id){var c=getCart();c[id]=(c[id]||0)+1;setCart(c);
|
|
270
|
+
var b=el('cartcount');if(b){b.classList.remove('pulse');void b.offsetWidth;b.classList.add('pulse');}
|
|
271
|
+
toast('✓ 已加入购物车 · 共 ' + cartCount() + ' 件');}
|
|
272
|
+
document.addEventListener('click',function(e){var t=e.target&&e.target.closest?e.target.closest('[data-add]'):null;if(t){addToCart(Number(t.getAttribute('data-add')));}});
|
|
273
|
+
function setQty(id,q){var c=getCart();if(q<=0)delete c[id];else c[id]=q;setCart(c);if(location.pathname.indexOf('/cart')>=0)viewCart();}
|
|
274
|
+
function getAuth(){try{return JSON.parse(localStorage.getItem(AUTH_KEY)||'null');}catch(e){return null;}}
|
|
275
|
+
function setAuth(a){if(a){localStorage.setItem(AUTH_KEY,JSON.stringify(a));}else{localStorage.removeItem(AUTH_KEY);}refreshAuthUI();}
|
|
276
|
+
function authHdr(){var h=hdrs();var a=getAuth();if(a&&a.token){h['Authorization']='Bearer '+a.token;}return h;}
|
|
277
|
+
function refreshAuthUI(){var box=el('autharea');if(!box)return;var a=getAuth();
|
|
278
|
+
if(a){box.innerHTML=' · <span style="color:#c7d2fe">'+esc(a.email)+'</span> <a href="#" onclick="doLogout();return false;" style="color:#aeb9d6">退出</a>'+(a.email===ADMIN_EMAIL?' <a href="./admin" style="color:#f6c453">管理台</a>':'');}
|
|
279
|
+
else{box.innerHTML=' · <a href="./login" style="color:#aeb9d6">登录</a> <a href="./signup" style="color:#aeb9d6">注册</a>';} }
|
|
280
|
+
function doLogout(){setAuth(null);location.href='./shop';}
|
|
281
|
+
function authCall(kind,email,pass){
|
|
282
|
+
var url = kind==='signup' ? AUTH+'/signup' : AUTH+'/token?grant_type=password';
|
|
283
|
+
return fetch(url,{method:'POST',headers:{'apikey':ANON,'Content-Type':'application/json'},
|
|
284
|
+
body:JSON.stringify({email:email,password:pass})})
|
|
285
|
+
.then(function(r){return r.json().then(function(j){return {ok:r.ok,status:r.status,body:j};});});
|
|
286
|
+
}
|
|
287
|
+
function viewLogin(){
|
|
288
|
+
var next=new URL(location.href).searchParams.get('next')||'./shop';
|
|
289
|
+
el('client-root').innerHTML='<input id="lemail" placeholder="邮箱"><input id="lpass" type="password" placeholder="密码">'+
|
|
290
|
+
'<div class="row" style="margin-top:10px"><button onclick="doLogin(\\''+esc(next)+'\\')">登录</button>'+
|
|
291
|
+
'<a class="btn ghost" href="./signup">注册买家</a></div><div class="muted" id="lmsg" style="margin-top:10px"></div>'+
|
|
292
|
+
'<div class="muted" style="margin-top:14px">管理员入口:<a href="./admin" style="color:#f6c453">./admin</a>(固定密码账号)</div>';
|
|
293
|
+
}
|
|
294
|
+
function doLogin(next){
|
|
295
|
+
var email=el('lemail').value.trim(),pass=el('lpass').value;
|
|
296
|
+
el('lmsg').textContent='登录中…';
|
|
297
|
+
authCall('login',email,pass).then(function(r){
|
|
298
|
+
if(r.ok&&r.body.access_token){setAuth({token:r.body.access_token,email:email});location.href=next;}
|
|
299
|
+
else{el('lmsg').textContent='登录失败: '+(r.body&&r.body.error_description||r.body&&r.body.msg||r.status);}
|
|
300
|
+
}).catch(function(e){el('lmsg').textContent='登录失败: '+e;});
|
|
301
|
+
}
|
|
302
|
+
function viewSignup(){
|
|
303
|
+
el('client-root').innerHTML='<input id="semail" placeholder="邮箱(买家账号)"><input id="spass" type="password" placeholder="密码(≥6位)">'+
|
|
304
|
+
'<div class="row" style="margin-top:10px"><button onclick="doSignup()">注册并登录</button>'+
|
|
305
|
+
'<a class="btn ghost" href="./login">已有账号登录</a></div><div class="muted" id="smsg" style="margin-top:10px"></div>';
|
|
306
|
+
}
|
|
307
|
+
function doSignup(){
|
|
308
|
+
var email=el('semail').value.trim(),pass=el('spass').value;
|
|
309
|
+
el('smsg').textContent='注册中…';
|
|
310
|
+
authCall('signup',email,pass).then(function(r){
|
|
311
|
+
if(r.ok&&r.body.access_token){setAuth({token:r.body.access_token,email:email});location.href='./shop';}
|
|
312
|
+
else if(r.ok){el('smsg').textContent='注册成功(需确认邮件场景未启用时请直接登录)';setTimeout(function(){location.href='./login';},800);}
|
|
313
|
+
else{el('smsg').textContent='注册失败: '+(r.body&&r.body.msg||r.status);}
|
|
314
|
+
}).catch(function(e){el('smsg').textContent='注册失败: '+e;});
|
|
315
|
+
}
|
|
316
|
+
function viewCart(){
|
|
317
|
+
var c=getCart(),ids=Object.keys(c);
|
|
318
|
+
if(!ids.length){el('client-root').innerHTML='<div class="empty">购物车为空。<a href="./shop" style="color:#aeb9d6">去逛逛 →</a></div>';return;}
|
|
319
|
+
getJSON(REST+'/products?select=id,name,price_cents,emoji').then(function(all){
|
|
320
|
+
var byId={};all.forEach(function(p){byId[p.id]=p;});
|
|
321
|
+
var total=0;
|
|
322
|
+
var html='<table><tr><th>商品</th><th>单价</th><th>数量</th><th>小计</th><th></th></tr>';
|
|
323
|
+
ids.forEach(function(id){var p=byId[id];if(!p)return;var q=c[id];total+=p.price_cents*q;
|
|
324
|
+
html+='<tr><td>'+esc(p.emoji||'')+' '+esc(p.name)+'</td><td>'+money(p.price_cents)+'</td>'+
|
|
325
|
+
'<td><button class="mini ghost" onclick="setQty('+id+','+(q-1)+')">−</button> '+q+' <button class="mini ghost" onclick="setQty('+id+','+(q+1)+')">+</button></td>'+
|
|
326
|
+
'<td>'+money(p.price_cents*q)+'</td><td><button class="mini danger" onclick="setQty('+id+',0)">移除</button></td></tr>';});
|
|
327
|
+
html+='</table><div class="row" style="margin-top:16px;justify-content:space-between"><span class="price" style="font-size:20px">合计 '+money(total)+'</span><a class="btn" href="./checkout">去结算 →</a></div>';
|
|
328
|
+
el('client-root').innerHTML=html;});
|
|
329
|
+
}
|
|
330
|
+
function viewCheckout(){
|
|
331
|
+
var a=getAuth();
|
|
332
|
+
if(!a){el('client-root').innerHTML='<div class="empty">下单需先登录(RLS 数据层强制)。<br><br><a class="btn" href="./login?next=./checkout">去登录 →</a> <a class="btn ghost" href="./signup">注册买家</a></div>';return;}
|
|
333
|
+
var c=getCart();if(!Object.keys(c).length){el('client-root').innerHTML='<div class="empty">购物车为空,无法结算。</div>';return;}
|
|
334
|
+
el('client-root').innerHTML='<div class="muted">下单账号:'+esc(a.email)+'(订单绑定买家邮箱,RLS 强制)</div>'+
|
|
335
|
+
'<input id="cname" placeholder="收货人姓名 *"><input id="cemail" placeholder="邮箱(可选)"><input id="caddr" placeholder="收货地址(可选)">'+
|
|
336
|
+
'<div class="row" style="margin-top:12px"><button onclick="placeOrder()">提交订单</button><a class="btn ghost" href="./cart">返回购物车</a></div><div class="muted" id="cmsg" style="margin-top:10px"></div>';
|
|
337
|
+
}
|
|
338
|
+
function placeOrder(){
|
|
339
|
+
var a=getAuth();if(!a){el('cmsg').textContent='未登录,无法下单';return;}
|
|
340
|
+
var name=el('cname').value.trim();if(!name){el('cmsg').textContent='请填写姓名';return;}
|
|
341
|
+
var email=el('cemail').value.trim(),addr=el('caddr').value.trim();
|
|
342
|
+
var c=getCart(),ids=Object.keys(c);el('cmsg').textContent='提交中…';
|
|
343
|
+
getJSON(REST+'/products?select=id,name,price_cents').then(function(all){
|
|
344
|
+
var byId={};all.forEach(function(p){byId[p.id]=p;});
|
|
345
|
+
var total=0;ids.forEach(function(id){if(byId[id])total+=byId[id].price_cents*c[id];});
|
|
346
|
+
return postJSON(REST+'/orders',{customer_name:name,customer_email:email,customer_address:addr,buyer_email:a.email,total_cents:total,status:'paid'},authHdr()).then(function(r){
|
|
347
|
+
if(!r.ok){throw new Error((r.body&&(r.body.message||r.body.code))||r.status);}
|
|
348
|
+
var ord=r.body;var orderId=(ord&&ord[0])?ord[0].id:ord.id;
|
|
349
|
+
var chain=Promise.resolve();
|
|
350
|
+
ids.forEach(function(id){var p=byId[id];if(!p)return;chain=chain.then(function(){
|
|
351
|
+
return postJSON(REST+'/order_items',{order_id:orderId,product_id:Number(id),product_name:p.name,qty:c[id],unit_price_cents:p.price_cents,buyer_email:a.email},authHdr()).then(function(r2){if(!r2.ok)throw new Error(r2.status);});});});
|
|
352
|
+
return chain.then(function(){return orderId;});});
|
|
353
|
+
}).then(function(orderId){setCart({});el('cmsg').textContent='下单成功, 订单号 '+orderId+'(已写入 MySQL, 绑定 '+a.email+')';
|
|
354
|
+
setTimeout(function(){location.href='./orders';},700);})
|
|
355
|
+
.catch(function(e){el('cmsg').textContent='下单失败(未登录会被 RLS 拒绝): '+e;});
|
|
356
|
+
}
|
|
357
|
+
function renderOrders(orders,itemsByOrder,heading){
|
|
358
|
+
if(!orders.length){el('client-root').innerHTML='<div class="empty">暂无订单。<a href="./shop" style="color:#aeb9d6">去下单 →</a></div>';return;}
|
|
359
|
+
el('client-root').innerHTML='<h3 style="margin-top:0">'+esc(heading)+'</h3>'+orders.map(function(o){var items=itemsByOrder[o.id]||[];
|
|
360
|
+
return '<div class="card" style="margin:12px 0"><div class="row" style="justify-content:space-between"><b>订单 #'+o.id+'</b><span class="muted">'+esc(o.customer_name)+' · '+esc(o.buyer_email||'')+' · '+esc(String(o.created_at||''))+'</span><span class="price">'+money(o.total_cents)+'</span></div>'+
|
|
361
|
+
'<table><tr><th>商品</th><th>单价</th><th>数量</th><th>小计</th></tr>'+items.map(function(it){return '<tr><td>'+esc(it.product_name)+'</td><td>'+money(it.unit_price_cents)+'</td><td>'+it.qty+'</td><td>'+money(it.unit_price_cents*it.qty)+'</td></tr>';}).join('')+'</table></div>';}).join('');
|
|
362
|
+
}
|
|
363
|
+
function loadOrders(withItems,heading){
|
|
364
|
+
var H=authHdr();
|
|
365
|
+
getJSON(REST+'/orders?select=*&order=id.desc',H).then(function(orders){
|
|
366
|
+
if(!Array.isArray(orders)){el('client-root').innerHTML='<div class="empty">读取订单失败(需登录)</div>';return;}
|
|
367
|
+
var chain=Promise.resolve();var itemsByOrder={};
|
|
368
|
+
if(withItems){orders.forEach(function(o){chain=chain.then(function(){return getJSON(REST+'/order_items?select=*&order_id=eq.'+o.id,H).then(function(items){itemsByOrder[o.id]=items;});});});}
|
|
369
|
+
chain.then(function(){renderOrders(orders,itemsByOrder,heading);});
|
|
370
|
+
}).catch(function(e){el('client-root').innerHTML='<div class="empty">读取订单失败: '+esc(String(e))+'</div>';});
|
|
371
|
+
}
|
|
372
|
+
function viewOrders(){
|
|
373
|
+
var a=getAuth();
|
|
374
|
+
if(!a){el('client-root').innerHTML='<div class="empty">查看订单需登录。<br><br><a class="btn" href="./login?next=./orders">去登录 →</a></div>';return;}
|
|
375
|
+
loadOrders(true,'我的订单(RLS 仅本人可见)');
|
|
376
|
+
}
|
|
377
|
+
function viewAdmin(){
|
|
378
|
+
var a=getAuth();
|
|
379
|
+
if(a&&a.email===ADMIN_EMAIL){loadOrders(true,'管理台 · 全部买家订单(RLS 管理员白名单)');return;}
|
|
380
|
+
el('client-root').innerHTML='<div class="muted">管理员固定账号:<code>'+ADMIN_EMAIL+'</code> / <code>admin-2026</code></div>'+
|
|
381
|
+
'<input id="aemail" value="'+ADMIN_EMAIL+'"><input id="apass" type="password" placeholder="管理员密码">'+
|
|
382
|
+
'<div class="row" style="margin-top:10px"><button onclick="doAdminLogin()">管理员登录</button></div><div class="muted" id="amsg" style="margin-top:10px"></div>';
|
|
383
|
+
}
|
|
384
|
+
function doAdminLogin(){
|
|
385
|
+
var email=el('aemail').value.trim(),pass=el('apass').value;
|
|
386
|
+
el('amsg').textContent='登录中…';
|
|
387
|
+
authCall('login',email,pass).then(function(r){
|
|
388
|
+
if(r.ok&&r.body.access_token){setAuth({token:r.body.access_token,email:email});viewAdmin();}
|
|
389
|
+
else{el('amsg').textContent='管理员登录失败: '+(r.body&&r.body.msg||r.status);}
|
|
390
|
+
}).catch(function(e){el('amsg').textContent='登录失败: '+e;});
|
|
391
|
+
}
|
|
392
|
+
(function(){
|
|
393
|
+
refreshBadge();refreshAuthUI();
|
|
394
|
+
var p=location.pathname;
|
|
395
|
+
if(p.indexOf('/cart')>=0)viewCart();
|
|
396
|
+
else if(p.indexOf('/checkout')>=0)viewCheckout();
|
|
397
|
+
else if(p.indexOf('/orders')>=0)viewOrders();
|
|
398
|
+
else if(p.indexOf('/login')>=0)viewLogin();
|
|
399
|
+
else if(p.indexOf('/signup')>=0)viewSignup();
|
|
400
|
+
else if(p.indexOf('/admin')>=0)viewAdmin();
|
|
401
|
+
})();
|
|
402
|
+
`;
|
|
403
|
+
return "<script>" + js.replace("__ANON__", anon) + "</script>";
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function pageDoc(bodyInner: React.ReactElement, serverIso: string, anon: string): string {
|
|
407
|
+
const body = renderToString(bodyInner);
|
|
408
|
+
return "<!DOCTYPE html><html lang=\"zh\"><head><meta charset=\"utf-8\">" +
|
|
409
|
+
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" +
|
|
410
|
+
"<title>Supabase Shop · React SSR</title><style>" + CSS + "</style></head><body>" +
|
|
411
|
+
"<div id=\"__ssr\" data-ssr=\"v1\">" + body + "</div>" +
|
|
412
|
+
"<footer>React 18 renderToString @ Hono 4.6.12 · SSR " + serverIso + " · 买家鉴权 GoTrue · 下单门禁 RLS · 管理员 admin@shop.demo</footer>" +
|
|
413
|
+
clientScript(anon) +
|
|
414
|
+
"</body></html>";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------- Hono 路由 ----------
|
|
418
|
+
const app = new Hono();
|
|
419
|
+
|
|
420
|
+
function norm(path: string): string {
|
|
421
|
+
return path.replace(/^\/app_[^/]+/, "") || path;
|
|
422
|
+
}
|
|
423
|
+
function catsOf(products: Product[]): string[] {
|
|
424
|
+
const cats = ["all"];
|
|
425
|
+
products.forEach((p) => { if (p.category && cats.indexOf(p.category) < 0) cats.push(p.category); });
|
|
426
|
+
return cats;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
app.get("*", async (c) => {
|
|
430
|
+
const p = norm(new URL(c.req.url).pathname);
|
|
431
|
+
const iso = new Date().toISOString();
|
|
432
|
+
const anon = anonKey();
|
|
433
|
+
const cfgErr = configError();
|
|
434
|
+
if (cfgErr) return c.html(configErrorDoc(cfgErr), 503);
|
|
435
|
+
|
|
436
|
+
if (p === "/" || p === "/index.html") return c.redirect("./shop", 302);
|
|
437
|
+
|
|
438
|
+
if (p === "/shop" || p === "/shop/") {
|
|
439
|
+
const q = (c.req.query("q") || "").trim();
|
|
440
|
+
const cat = c.req.query("cat") || "all";
|
|
441
|
+
const products = await fetchProducts();
|
|
442
|
+
const note = products.length ? "服务端取数 OK" : "内网基址不可达或无商品(降级空目录)";
|
|
443
|
+
return c.html(pageDoc(
|
|
444
|
+
h(React.Fragment, null,
|
|
445
|
+
h(Header, { base: "./" }),
|
|
446
|
+
h(SearchBar, { q, cat, cats: catsOf(products) }),
|
|
447
|
+
h("main", null, h(CatalogView, { products, q, cat, cats: catsOf(products), note }))),
|
|
448
|
+
iso, anon));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const pm = p.match(/^\/product\/(\d+)$/);
|
|
452
|
+
if (pm) {
|
|
453
|
+
const prod = await fetchProduct(pm[1]);
|
|
454
|
+
return c.html(pageDoc(
|
|
455
|
+
h(React.Fragment, null, h(Header, { base: "../" }), h("main", null, h(ProductView, { p: prod, base: "../" }))),
|
|
456
|
+
iso, anon));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (p === "/cart" || p === "/checkout" || p === "/orders" || p === "/login" || p === "/signup" || p === "/admin") {
|
|
460
|
+
const titles: Record<string, [string, string]> = {
|
|
461
|
+
"/cart": ["购物车", "浏览器 hydrate 直连 REST(localStorage 购物车)"],
|
|
462
|
+
"/checkout": ["结算", "需登录:订单绑定买家邮箱,RLS 数据层强制门禁"],
|
|
463
|
+
"/orders": ["我的订单", "需登录:RLS 仅本人订单可见(管理员见全量)"],
|
|
464
|
+
"/login": ["买家登录", "GoTrue 密码登录 · 会话存 localStorage · 浏览器直连鉴权"],
|
|
465
|
+
"/signup": ["买家注册", "游客注册成为买家 · GoTrue signup(免邮箱确认已开)"],
|
|
466
|
+
"/admin": ["后台管理", "固定密码账号 admin@shop.demo / admin-2026 · RLS 白名单看全量订单"],
|
|
467
|
+
};
|
|
468
|
+
const t = titles[p];
|
|
469
|
+
return c.html(pageDoc(
|
|
470
|
+
h(React.Fragment, null, h(Header, { base: "./" }), h("main", null,
|
|
471
|
+
h(ClientShell, { title: t[0], hint: t[1] }))),
|
|
472
|
+
iso, anon));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (p === "/api/dbg" || p === "/dbg") {
|
|
476
|
+
const info: Record<string, unknown> = { hasUrl: !!Deno.env.get("SUPABASE_URL"), hasAnon: !!Deno.env.get("SUPABASE_ANON_KEY"), base: internalBase() };
|
|
477
|
+
try {
|
|
478
|
+
const r = await fetch(internalBase() + "/rest/v1/products?select=id", { headers: { apikey: anonKey() } });
|
|
479
|
+
info.fetchStatus = r.status;
|
|
480
|
+
} catch (e) { info.fetchErr = String(e).slice(0, 160); }
|
|
481
|
+
return c.json(info);
|
|
482
|
+
}
|
|
483
|
+
if (p === "/api/health" || p === "/health") return c.json({ ok: true, engine: "hono@4.6.12", ssr: "react@18.3.1" });
|
|
484
|
+
if (p === "/api/products" || p === "/products") return c.json(await fetchProducts());
|
|
485
|
+
|
|
486
|
+
return c.html(pageDoc(
|
|
487
|
+
h(React.Fragment, null, h(Header, { base: "./" }), h("main", null, h("div", { className: "empty" }, "404 · 页面不存在 " + p))),
|
|
488
|
+
iso, anon), 404);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
Deno.serve(app.fetch);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Declarative RLS for the shop webapp. Applied by `pxbaas app db-init` (single-tenant AND multi-tenant). table_schema is NEVER declared here: single-tenant binds it to --schema (default public); multi-tenant binds it server-side to the tenant db. using/check expressions may embed {{request.jwt.claim.*}} templates.",
|
|
3
|
+
"tables": [
|
|
4
|
+
{ "table": "products", "rlsEnabled": true, "rlsForced": false },
|
|
5
|
+
{ "table": "orders", "rlsEnabled": true, "rlsForced": false },
|
|
6
|
+
{ "table": "order_items", "rlsEnabled": true, "rlsForced": false }
|
|
7
|
+
],
|
|
8
|
+
"policies": [
|
|
9
|
+
{ "table": "products", "name": "products_select_anon", "role": "anon", "command": "SELECT", "using": "1=1", "check": null, "permissive": true },
|
|
10
|
+
{ "table": "products", "name": "products_select_auth", "role": "authenticated", "command": "SELECT", "using": "1=1", "check": null, "permissive": true },
|
|
11
|
+
{ "table": "orders", "name": "orders_select_auth", "role": "authenticated", "command": "SELECT", "using": "`buyer_email` = {{request.jwt.claim.email}} OR {{request.jwt.claim.email}} = 'admin@shop.demo'", "check": null, "permissive": true },
|
|
12
|
+
{ "table": "orders", "name": "orders_insert_auth", "role": "authenticated", "command": "INSERT", "using": null, "check": "`buyer_email` = {{request.jwt.claim.email}}", "permissive": true },
|
|
13
|
+
{ "table": "order_items", "name": "order_items_select_auth", "role": "authenticated", "command": "SELECT", "using": "`buyer_email` = {{request.jwt.claim.email}} OR {{request.jwt.claim.email}} = 'admin@shop.demo'", "check": null, "permissive": true },
|
|
14
|
+
{ "table": "order_items", "name": "order_items_insert_auth", "role": "authenticated", "command": "INSERT", "using": null, "check": "`buyer_email` = {{request.jwt.claim.email}}", "permissive": true }
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- shop-demo-ssr — 数据层(幂等,可重复执行)
|
|
3
|
+
--
|
|
4
|
+
-- 用法:把 __SCHEMA__ 替换为目标库名后逐条执行
|
|
5
|
+
-- · 单租户实例:__SCHEMA__ = public
|
|
6
|
+
-- · 多租户实例:__SCHEMA__ = tenant_<tenant_id>(每租户一套库)
|
|
7
|
+
-- sed 's/__SCHEMA__/public/g' db/schema.sql
|
|
8
|
+
--
|
|
9
|
+
-- 执行通道(推荐):`pxbaas app db-init <name>` —— 单/多租户通用,自动:
|
|
10
|
+
-- · 单租户:__SCHEMA__ → public,经 /pg/query(全局 admin)执行;
|
|
11
|
+
-- · 多租户:__SCHEMA__ 去限定(连接已锁租户库),经 /api/tenant-ddl 用
|
|
12
|
+
-- per-tenant service_role key 执行;GRANT 仅本租户库,天然隔离。
|
|
13
|
+
-- 本文件只含 DDL + 种子;RLS 声明在同目录 db/rls.json,由 db-init 一并应用。
|
|
14
|
+
--
|
|
15
|
+
-- ⚠️ RLS 元数据的落点(写错静默失效,等价于关闭 RLS):
|
|
16
|
+
-- 永远写中心库 supabase._mysqlrest_rls_tables / _mysqlrest_rls_policies。
|
|
17
|
+
-- db-init 会自动绑定 table_schema(单租户=--schema,多租户=服务端强绑租户库),
|
|
18
|
+
-- 无需也不要手写库名。租户库内同名 _mysqlrest_rls_* 表没有任何服务读取,勿写入。
|
|
19
|
+
-- =============================================================================
|
|
20
|
+
|
|
21
|
+
-- ---- 表结构 ----
|
|
22
|
+
CREATE TABLE IF NOT EXISTS `__SCHEMA__`.`products` (
|
|
23
|
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
24
|
+
name VARCHAR(200) NOT NULL,
|
|
25
|
+
description TEXT,
|
|
26
|
+
price_cents INT NOT NULL DEFAULT 0,
|
|
27
|
+
stock INT NOT NULL DEFAULT 0,
|
|
28
|
+
emoji VARCHAR(16) DEFAULT '',
|
|
29
|
+
category VARCHAR(64),
|
|
30
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE IF NOT EXISTS `__SCHEMA__`.`orders` (
|
|
34
|
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
35
|
+
customer_name VARCHAR(120),
|
|
36
|
+
customer_email VARCHAR(200),
|
|
37
|
+
customer_address TEXT,
|
|
38
|
+
buyer_email VARCHAR(200), -- 下单者邮箱,RLS owner 判据(缺失则下单必失败)
|
|
39
|
+
total_cents INT NOT NULL DEFAULT 0,
|
|
40
|
+
status VARCHAR(32) DEFAULT 'paid',
|
|
41
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
-- order_items 反规范化存 product_name/unit_price_cents,规避 join;
|
|
45
|
+
-- buyer_email 冗余存储作为 owner RLS 判据(无法 join orders,故必须自带 owner 列,
|
|
46
|
+
-- 否则任意 authenticated 用户可读/写全部明细 —— R2-05 越权)。
|
|
47
|
+
CREATE TABLE IF NOT EXISTS `__SCHEMA__`.`order_items` (
|
|
48
|
+
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
49
|
+
order_id INT NOT NULL,
|
|
50
|
+
product_id INT NOT NULL,
|
|
51
|
+
product_name VARCHAR(200),
|
|
52
|
+
qty INT NOT NULL DEFAULT 1,
|
|
53
|
+
unit_price_cents INT NOT NULL DEFAULT 0,
|
|
54
|
+
buyer_email VARCHAR(200), -- 明细归属者邮箱,RLS owner 判据(写入须 = jwt.email)
|
|
55
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
-- ---- RLS 注册与策略:声明在同目录 db/rls.json,由 `pxbaas app db-init` 应用 ----
|
|
59
|
+
-- · products:公开目录,anon / authenticated 均可读;
|
|
60
|
+
-- · orders / order_items:仅本人(buyer_email = jwt.email)或管理员白名单可读,仅本人可写。
|
|
61
|
+
-- (不再内联 INSERT,避免与 rls.json 双源;如需手动注册见 db/rls.json。)
|
|
62
|
+
|
|
63
|
+
-- ---- 种子商品(10 件,幂等)----
|
|
64
|
+
INSERT INTO `__SCHEMA__`.`products` (name, description, price_cents, stock, emoji, category)
|
|
65
|
+
SELECT * FROM (
|
|
66
|
+
SELECT '机械键盘' a, '87 键热插拔 · RGB' b, 29900 c, 50 d, '⌨️' e, '外设' f
|
|
67
|
+
UNION ALL SELECT '降噪耳机', '40h 续航 · ANC', 59900, 30, '🎧', '音频'
|
|
68
|
+
UNION ALL SELECT '智能音箱', '远场语音 · 家居中枢', 19900, 80, '🔊', '音频'
|
|
69
|
+
UNION ALL SELECT '运动手表', '血氧 · GPS · 14 天续航', 89900, 25, '⌚', '穿戴'
|
|
70
|
+
UNION ALL SELECT 'GaN 充电器', '65W 三口快充', 12900, 200, '🔌', '充电'
|
|
71
|
+
UNION ALL SELECT '桌面收纳架', '铝合金 · 三层', 8900, 120, '🗄️', '收纳'
|
|
72
|
+
UNION ALL SELECT 'WiFi7 路由', '2.5G 网口 · Mesh', 49900, 40, '📡', '网络'
|
|
73
|
+
UNION ALL SELECT '四盘位 NAS', '家用私有云 · 转码', 259900, 10, '💾', '网络'
|
|
74
|
+
UNION ALL SELECT '4K 显示器', '27 寸 · HDR600', 189900, 20, '🖥️', '显示'
|
|
75
|
+
UNION ALL SELECT '电竞椅', '人体工学 · 4D 扶手', 129900, 15, '🪑', '家具'
|
|
76
|
+
) t WHERE NOT EXISTS (SELECT 1 FROM `__SCHEMA__`.`products`);
|
|
77
|
+
|
|
78
|
+
-- ---- 管理员账号(不是 SQL,走该实例/租户的 GoTrue)----
|
|
79
|
+
-- curl -X POST {endpoint}/auth/v1/signup -H "apikey: <anon>" \
|
|
80
|
+
-- -H 'Content-Type: application/json' \
|
|
81
|
+
-- -d '{"email":"admin@shop.demo","password":"admin-2026"}'
|
|
82
|
+
-- 多租户时 anon 用该租户的 key(自带 tenant_id claim,无需 X-Tenant-Id)。
|
|
83
|
+
|
|
84
|
+
-- ---- 验收(应逐条成立)----
|
|
85
|
+
-- 1) anon 读 products → 200 且 10 行;
|
|
86
|
+
-- 2) anon 读 orders → 200 且 [](无 anon 策略);
|
|
87
|
+
-- 3) 买家 token 写 orders(buyer_email=自己)→ 201;
|
|
88
|
+
-- 4) 买家 token 写 orders(buyer_email=他人)→ 403 PGRST302;
|
|
89
|
+
-- 5) 买家读 orders → 只见自己;admin@shop.demo 读 orders → 见全量。
|
|
90
|
+
-- 6) 买家读 order_items → 只见自己 buyer_email 的明细;用户 B 读/写用户 A 的订单明细 → 读 0 行 / 写 403 PGRST302。
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta http-equiv="refresh" content="0; url=./shop">
|
|
6
|
+
<title>Supabase Shop · React SSR</title>
|
|
7
|
+
<style>body{font-family:sans-serif;background:#0b1020;color:#e8eaf2;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}</style>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<p>进入 <a href="./shop" style="color:#8b9cf7">React SSR 商店</a>…</p>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|