feat: add publish-app skill; rename dev-app to app-dev
- publish-app: convert/add Gitea Actions deploy job to publish TickShield apps to the marketplace via aigrego-release (Go static binary) — --store auto-publish + --wait review gate; local-first CLI fetch (ensure-cli.sh) with minIO platform-matrix download + sha256 verify; workflow template and 12-item check script - rename dev-app to app-dev per app-centric naming convention (no tickshield- prefix), documented in AGENTS.md and README - app-dev boundary routes CI publish workflow work to publish-app; both skills are project-level installs only
This commit is contained in:
1 parent
5c9886ac24
commit
8abc6a4abf
25 files changed
+524
-5
No files matched your search
@@ -0,0 +1,18 @@
|
||||
# TickShield 生态应用 Dockerfile(service 与 micro 后端通用)。
|
||||
# 硬约束:FROM 基础镜像 + symlink 预置依赖 + 不覆盖 CMD。
|
||||
# 构建上下文 = 本仓库根(docker build -f Dockerfile . 或 compose build.context 指到仓根)。
|
||||
FROM tickshield-node-runtime:base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY server.js package.json app.manifest.json ./
|
||||
|
||||
# 让 import 解析到基础镜像预置的 @aigrego/guard|logger|tracing 与 pg。
|
||||
RUN ln -s /opt/xgent/runtime/node_modules /app/node_modules
|
||||
|
||||
# 默认值即此,显式写出便于审阅;不要覆盖 CMD/ENTRYPOINT(注册/心跳/优雅下线/health
|
||||
# 全在基础镜像 runtime-entry.js)。
|
||||
ENV APP_ENTRY=/app/server.js MANIFEST_PATH=/app/app.manifest.json
|
||||
|
||||
# micro 型前端产物不进镜像:CI 里 bun 构建 web/ 后经 aigrego-release publish --dist 上传,
|
||||
# 由 Portal 同源托管 /apps/<key>/_/*。
|
||||
@@ -0,0 +1,80 @@
|
||||
# 刻盾云(TickShield)<应用名称> 应用 – 需求规格书 v0.1
|
||||
|
||||
**项目代号**:TickShield
|
||||
**应用名称**:<应用名称>
|
||||
**listingKey**:`<listingKey>`
|
||||
**文档版本**:v0.1
|
||||
**文档状态**:草稿
|
||||
**对标产品**:<对标产品>
|
||||
|
||||
> 本文是 TickShield 平台生态应用 `<listingKey>` 的需求规格书,严格对齐平台接入规范
|
||||
> (`app.manifest.json` 契约、四道闸鉴权、`tenant_id` 多租户隔离、`@aigrego/portal-sdk`
|
||||
> 前端集成、`@aigrego/release-cli` 发布)。平台基座契约以平台仓 `docs/PRD_v4.0.md` 与
|
||||
> `docs/Architecture_v4.0.md` 为准。
|
||||
|
||||
---
|
||||
|
||||
## 一、产品定位与目标
|
||||
|
||||
### 1.1 定位
|
||||
|
||||
<一段话:这个应用是什么、给谁用、解决什么问题。>
|
||||
|
||||
### 1.2 目标(Goals)
|
||||
|
||||
- **G1** <目标,可验收>
|
||||
- **G2** <...>
|
||||
|
||||
### 1.3 非目标(Non-Goals)
|
||||
|
||||
- **NG1** <明确不做什么>
|
||||
- **NG2** <...>
|
||||
|
||||
## 二、用户故事
|
||||
|
||||
- 作为 <角色>,我希望 <行为>,以便 <价值>。
|
||||
|
||||
## 三、应用接入规范(TickShield 契约)
|
||||
|
||||
- **listingKey**:`<listingKey>`(四位一体:TDT aud == 容器名 == 网络别名 == scope 命名空间,不可变更)
|
||||
- **类型**:`micro` / `service`
|
||||
- **scopes**:`<listingKey>.read` / `<listingKey>.write`(治理字段,变更进平台审核队列)
|
||||
- **aclManifest 页面与权限点**:<列出 pages/actions,PID = `<listingKey>:page|action:<key>`>
|
||||
- **exchangeTargets**:<跨应用调用目标,无则写「无」>
|
||||
- **数据表与租户隔离**:<表清单,均含 tenant_id 列;全部 SQL 经 @aigrego/guard tdb>
|
||||
- **部署形态**:镜像 `FROM tickshield-node-runtime:base`;监听 `PORT`(默认 8080);环境变量 <列出业务 env>
|
||||
|
||||
## 四、功能需求(FR)
|
||||
|
||||
| 编号 | 需求 | 验收标准 |
|
||||
|---|---|---|
|
||||
| FR-1 | <...> | <...> |
|
||||
|
||||
## 五、非功能需求(NFR)
|
||||
|
||||
| 编号 | 需求 | 指标 |
|
||||
|---|---|---|
|
||||
| NFR-1 | 性能 | <如 单取 P99≤50ms> |
|
||||
| NFR-2 | 安全 | 四道闸矩阵全绿;跨租户空集;敏感值零命中日志 |
|
||||
|
||||
## 六、API 契约
|
||||
|
||||
| 方法 | 路径 | 闸口 spec | 说明 |
|
||||
|---|---|---|---|
|
||||
| GET | `/v1/items` | `scope: <listingKey>.read` | <...> |
|
||||
| POST | `/v1/items` | `scope: <listingKey>.write` | <...> |
|
||||
|
||||
> 路径禁 `/api` `/auth` `/svc` 前缀(平台保留通道)。
|
||||
|
||||
## 七、验收清单
|
||||
|
||||
- [ ] 注册生效(`/api/services` 出现且心跳鲜活)
|
||||
- [ ] `/health` 恰两键 `{"status":"ok","db":"ok"}`(直连 + 经网关)
|
||||
- [ ] 四道闸矩阵:匿名 401 / 跨 aud 401·403 / 无 scope 403 / role 403 / acl 403 / 合法 200
|
||||
- [ ] 租户隔离:跨租户读 = 空集
|
||||
- [ ] (micro)SDK 握手 + acl 显隐 + callService 通
|
||||
- [ ] 发布演练:`publish --manifest/--dist/--image` 全链路
|
||||
|
||||
## 八、范围边界与开放问题
|
||||
|
||||
- OQ-1:<开放问题>
|
||||
@@ -0,0 +1,5 @@
|
||||
# 应用身份配置(本文件是模板;真配置复制为 .aigrego-registry.env 并务必进 .gitignore)。
|
||||
# release-cli 从配置文件只读 LISTING_KEY——APP_SECRET 写在这里会被告警并忽略(密钥不落盘)。
|
||||
LISTING_KEY=my-app
|
||||
# 平台地址建议走环境变量 AIGREGO_PORTAL_URL(随 dev/staging/prod 变),此处仅作缺省备注。
|
||||
# AIGREGO_PORTAL_URL=http://localhost:8080
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"listingKey": "my-app",
|
||||
"type": "micro",
|
||||
"name": "我的应用",
|
||||
"version": "0.1.0",
|
||||
"icon": "box",
|
||||
"color": "#0d9488",
|
||||
"cat": "tools",
|
||||
"tagline": "一句话说明(≤120 字符)",
|
||||
"desc": "应用描述(≤2000 字符)。",
|
||||
"scopes": ["my-app.read", "my-app.write"],
|
||||
"exchangeTargets": [],
|
||||
"navItems": [
|
||||
{ "id": "home", "label": "首页", "path": "/", "icon": "house" },
|
||||
{ "id": "settings", "label": "设置", "path": "/settings", "icon": "settings" }
|
||||
],
|
||||
"footerNavItems": [
|
||||
{ "id": "help", "label": "帮助", "path": "/help", "icon": "circle-help" }
|
||||
],
|
||||
"dashboard.widgets": [],
|
||||
"aclManifest": {
|
||||
"pages": [
|
||||
{
|
||||
"key": "home",
|
||||
"label": "首页",
|
||||
"audience": "viewer",
|
||||
"defaultForMember": true,
|
||||
"actions": [
|
||||
{ "key": "item.create", "label": "新建条目" },
|
||||
{ "key": "item.delete", "label": "删除条目" }
|
||||
]
|
||||
},
|
||||
{ "key": "settings", "label": "设置", "audience": "admin", "defaultForMember": false }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"listingKey": "my-app",
|
||||
"type": "service",
|
||||
"name": "我的应用",
|
||||
"version": "0.1.0",
|
||||
"icon": "🧩",
|
||||
"color": "#2563eb",
|
||||
"cat": "tools",
|
||||
"tagline": "一句话说明(≤120 字符)",
|
||||
"desc": "应用描述(≤2000 字符)。",
|
||||
"scopes": ["my-app.read", "my-app.write"],
|
||||
"exchangeTargets": [],
|
||||
"navItems": [],
|
||||
"footerNavItems": [
|
||||
{ "id": "docs", "label": "接入文档", "path": "/docs", "icon": "📖" }
|
||||
],
|
||||
"dashboard.widgets": [],
|
||||
"aclManifest": {
|
||||
"pages": [
|
||||
{
|
||||
"key": "settings",
|
||||
"label": "设置",
|
||||
"audience": "admin",
|
||||
"defaultForMember": false,
|
||||
"actions": [{ "key": "settings.manage", "label": "管理设置" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Gitea Actions 应用自动上架模板(APPSTORE-RUNTIME)——复制到应用仓库 .gitea/workflows/ 下使用。
|
||||
#
|
||||
# 链路:推 tag → buildx 构建推送镜像到平台 registry → 经 release-cli(单文件 CLI,
|
||||
# node 容器内执行,runner 无需装 node)提交 manifest → 记录镜像 → 自动上架。
|
||||
#
|
||||
# 前置:
|
||||
# 1. 平台商店「新建应用」签发凭证,配到仓库 secrets:APP_KEY / APP_SECRET;
|
||||
# 2. 商店应用详情开「auto_publish」(否则 CI 停在记录镜像,等平台管理员手动上架);
|
||||
# 3. registry 推送凭证 secrets:REGISTRY_USER / REGISTRY_TOKEN(推送 livebook:8418 用)。
|
||||
name: app-release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout tag source
|
||||
run: |
|
||||
rm -rf src && git clone --depth 1 --branch "$GITHUB_REF_NAME" . src
|
||||
cd src && echo "commit: $(git rev-parse --short HEAD)"
|
||||
|
||||
- name: Build & push image(多架构)
|
||||
run: |
|
||||
IMAGE="${{ vars.APP_IMAGE_PREFIX || 'livebook:8418/projects' }}/$(basename $GITHUB_REPOSITORY)"
|
||||
TAG="${GITHUB_REF_NAME#v}"
|
||||
docker login -u "${{ secrets.REGISTRY_USER }}" -p "${{ secrets.REGISTRY_TOKEN }}" livebook:8418
|
||||
docker buildx build --platform linux/amd64,linux/arm64 --push \
|
||||
-t "$IMAGE:$TAG" -t "$IMAGE:latest" ./src
|
||||
echo "IMAGE_REF=$IMAGE:$TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Submit manifest + record image + auto publish
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e AIGREGO_PORTAL_URL="${{ vars.TICKSHIELD_PORTAL_URL || 'http://gateway:8080' }}" \
|
||||
-e APP_KEY="${{ secrets.APP_KEY }}" \
|
||||
-e APP_SECRET="${{ secrets.APP_SECRET }}" \
|
||||
-v "$PWD/src:/src" node:20-alpine \
|
||||
sh -c 'node /src/cli/aigrego-release.mjs publish \
|
||||
--manifest /src/app.manifest.json \
|
||||
--image "$0" --store --wait 600' "$IMAGE_REF"
|
||||
# CLI 单文件获取(仓库内不落盘时):
|
||||
# sh -c 'wget -qO /tmp/cli.mjs https://code.innev.cn/images/tickshield-dist/raw/branch/main/cli/aigrego-release.mjs && node /tmp/cli.mjs ...'
|
||||
# micro 应用前端产物(先 bun 构建 web/ 再上传,包根必须直接含 index.html):
|
||||
# sh -c 'cd /src/web && bun build src/main.tsx --outfile dist/app.bundle.js && node /src/cli/aigrego-release.mjs publish --dist dist'
|
||||
@@ -0,0 +1,130 @@
|
||||
// my-app 业务入口(APP_ENTRY)。契约:导出 { handler, dbCheck? } 的 ESM 模块。
|
||||
// 注册/心跳/优雅下线//health 由基础镜像 runtime-entry.js 全包——本文件不监听端口、
|
||||
// 不写注册代码。启动副作用写模块顶层(top-level await),失败必须 process.exit(1)。
|
||||
//
|
||||
// 占位说明:全局替换 my-app → 你的 listingKey;my_table → 你的业务表;
|
||||
// 路由表演示四种闸口组合,按业务改写。
|
||||
import pg from 'pg';
|
||||
import { createGuard } from '@aigrego/guard';
|
||||
import { createLogger } from '@aigrego/logger';
|
||||
|
||||
const PORTAL_API_URL = (process.env.PORTAL_API_URL || 'http://portal:3000').replace(/\/+$/, '');
|
||||
const APP_KEY = process.env.APP_KEY || 'my-app';
|
||||
const APP_SECRET = process.env.APP_SECRET || '';
|
||||
const DATABASE_URL = process.env.DATABASE_URL || '';
|
||||
|
||||
const logger = createLogger({ service: 'my-app' });
|
||||
const guard = createGuard({ portalUrl: PORTAL_API_URL, appKey: APP_KEY, appSecret: APP_SECRET });
|
||||
|
||||
// 租户数据源:全部业务 SQL 的唯一合法通路(租户隔离红线)。
|
||||
export const tdb = guard.createTenantDb({
|
||||
defaultDsn: DATABASE_URL,
|
||||
envPrefix: 'TENANT_DS_',
|
||||
redisUrl: process.env.REDIS_URL, // FR-30 热迁移即时切库;缺省降级 60s 轮询
|
||||
createPool: (dsn) => new pg.Pool({ connectionString: dsn }),
|
||||
});
|
||||
|
||||
// /health 探活专用池(SELECT 1)——唯一被认可的裸 pg 用法。
|
||||
const checkPool = new pg.Pool({ connectionString: DATABASE_URL, max: 1 });
|
||||
|
||||
try {
|
||||
await tdb.start();
|
||||
await guard.assertTenantTables(tdb, ['my_table']); // 每张业务表必须含 tenant_id 列
|
||||
logger.info('startup checks passed', { tables: ['my_table'] });
|
||||
} catch (error) {
|
||||
logger.error('startup checks failed, refusing to serve', { error: error?.message });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function sendJson(res, status, payload) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function readJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', (chunk) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
try {
|
||||
resolve(chunks.length > 0 ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// 路由表:闸口顺序固定 identity(401) → audience → scope → role → acl(403)。
|
||||
// 路径禁 /api /auth /svc 前缀(平台保留通道),用 /v1/ 之类。
|
||||
const dispatch = guard.routes({
|
||||
'GET /v1/items': {
|
||||
scope: 'my-app.read',
|
||||
handler: async (req, res) => {
|
||||
// SELECT 自动改写追加 tenant_id=$N(参数占位)。
|
||||
const { rows } = await tdb.query(
|
||||
'SELECT id, title, created_at FROM my_table ORDER BY created_at DESC',
|
||||
);
|
||||
sendJson(res, 200, { items: rows });
|
||||
},
|
||||
},
|
||||
'POST /v1/items': {
|
||||
scope: 'my-app.write',
|
||||
handler: async (req, res, claims) => {
|
||||
let body;
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch {
|
||||
sendJson(res, 400, { error: 'invalid_json' });
|
||||
return;
|
||||
}
|
||||
const title = typeof body?.title === 'string' ? body.title.trim() : '';
|
||||
if (!title) {
|
||||
sendJson(res, 400, { error: 'title_required' });
|
||||
return;
|
||||
}
|
||||
// INSERT 不改写——tenant_id 由应用显式写入(第一参 claims.tenant_id)。
|
||||
const { rows } = await tdb.query(
|
||||
'INSERT INTO my_table (tenant_id, title, created_by) VALUES ($1,$2,$3) RETURNING id',
|
||||
[claims.tenant_id, title, claims.sub],
|
||||
);
|
||||
sendJson(res, 201, { id: rows[0].id });
|
||||
},
|
||||
},
|
||||
'GET /v1/admin/stats': {
|
||||
role: 'admin', // 服务态令牌无 role,天然过不了此闸
|
||||
handler: async (req, res, claims) => {
|
||||
const { rows } = await tdb.query('SELECT count(*)::int AS n FROM my_table');
|
||||
sendJson(res, 200, { count: rows[0].n, tenant: claims.tenant_id });
|
||||
},
|
||||
},
|
||||
'GET /v1/settings': {
|
||||
scope: 'my-app.read',
|
||||
role: 'admin',
|
||||
pid: 'my-app:page:settings', // acl 闸:对照 aclManifest.pages
|
||||
handler: async (req, res) => {
|
||||
sendJson(res, 200, { settings: {} });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// runtime-entry 已把 handler 包进 tracing ALS(入站 traceparent 续链),
|
||||
// logger 自动带 trace_id;自建出站 HTTP 调用记得注入 traceparent 头。
|
||||
export async function handler(req, res) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await dispatch(req, res);
|
||||
} finally {
|
||||
logger.info('request', {
|
||||
method: req.method,
|
||||
path: String(req.url || '').split('?')[0],
|
||||
status: res.statusCode,
|
||||
ms: Date.now() - start,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function dbCheck() {
|
||||
await checkPool.query('SELECT 1');
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<!-- micro 应用托管页:资源一律绝对路径 /apps/<key>/_/ 引用(门户同源托管 /apps/<key>/_/*)。
|
||||
PROD CSP 严格 default-src 'self'——禁止内联脚本,JS 必须外置文件。
|
||||
portal-sdk 用 vendor 版全局注入(不打进 bundle,桥协议版本与宿主同源)。
|
||||
占位:全局替换 my-app → 你的 listingKey。 -->
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>我的应用</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/apps/my-app/_/vendor/portal-sdk.js"></script>
|
||||
<script src="/apps/my-app/_/app.bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "my-app-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "my-app 微应用前端:bun 单文件构建,portal-sdk 经 vendor 全局注入(不打包 SDK)。",
|
||||
"scripts": {
|
||||
"build": "bun build src/main.tsx --outfile dist/app.bundle.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// 后端调用封装:一律经 sdk.callService 走宿主代发(宿主铸 aud=<key> 用户态 TDT
|
||||
// 代发 /svc/<key><path>,iframe 零跨域),不直接 fetch /svc/...。
|
||||
// 业务错误约定 200+{ok:false,error:{code,message}} 信封;闸口错误(401/403)走传输层状态码。
|
||||
// 占位:全局替换 my-app → 你的 listingKey。
|
||||
import { sdk } from './sdk';
|
||||
|
||||
const APP_KEY = 'my-app';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function callApi<T = unknown>(
|
||||
path: string,
|
||||
init?: { method?: string; body?: unknown },
|
||||
): Promise<T> {
|
||||
if (!sdk) throw new ApiError(0, 'no_host', '必须在门户内打开');
|
||||
const out = await sdk.callService<{ ok?: boolean; error?: { code: string; message: string } } & T>(
|
||||
APP_KEY,
|
||||
path,
|
||||
{
|
||||
method: init?.method ?? 'GET',
|
||||
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
headers: init?.body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
||||
},
|
||||
);
|
||||
if (out && typeof out === 'object' && 'ok' in out && out.ok === false) {
|
||||
throw new ApiError(200, out.error?.code ?? 'unknown', out.error?.message ?? 'unknown');
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// micro 应用前端入口:ready() 握手 → acl 驱动路由守卫 → callService 调自有后端。
|
||||
// i18n 约定:只读宿主 localStorage['ts-lang'](zh-CN/en-US)+ 监听 storage 事件实时切换,
|
||||
// 不反写;正式应用照此建 i18n 字典(zh-CN 为唯一事实来源),本骨架从简。
|
||||
// 占位:全局替换 my-app → 你的 listingKey。
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { sdk, initialRoute, canPage, type InitPayloadLike } from './sdk';
|
||||
import { callApi } from './api';
|
||||
|
||||
function HomePage() {
|
||||
const [items, setItems] = useState<{ id: string; title: string }[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
useEffect(() => {
|
||||
callApi<{ items: { id: string; title: string }[] }>('/v1/items')
|
||||
.then((out) => setItems(out.items ?? []))
|
||||
.catch((e) => setError(String(e?.message ?? e)));
|
||||
}, []);
|
||||
return (
|
||||
<main style={{ padding: 16 }}>
|
||||
<h1>首页</h1>
|
||||
{error && <p role="alert">加载失败:{error}</p>}
|
||||
<ul>
|
||||
{items.map((it) => (
|
||||
<li key={it.id}>{it.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
return (
|
||||
<main style={{ padding: 16 }}>
|
||||
<h1>设置</h1>
|
||||
<p>仅管理员可见(前端 acl 是 UX 闸,后端四道闸才是真闸)。</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [init, setInit] = useState<InitPayloadLike | null>(null);
|
||||
const [route, setRoute] = useState(initialRoute());
|
||||
const [failed, setFailed] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!sdk) {
|
||||
setFailed('请在门户内打开本应用(宿主 iframe 桥不可用)。');
|
||||
return;
|
||||
}
|
||||
sdk.ready()
|
||||
.then((payload) => setInit(payload))
|
||||
.catch((e) => setFailed(String(e?.message ?? e)));
|
||||
}, []);
|
||||
|
||||
function go(path: string) {
|
||||
setRoute(path);
|
||||
sdk?.routeSync(path); // 深链上报:刷新后宿主经 ?r= 恢复
|
||||
}
|
||||
|
||||
if (failed) return <main style={{ padding: 16 }}>{failed}</main>;
|
||||
if (!init) return <main style={{ padding: 16 }}>加载中…</main>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav style={{ display: 'flex', gap: 12, padding: 12 }}>
|
||||
{canPage(init, 'home') && <button onClick={() => go('/')}>首页</button>}
|
||||
{canPage(init, 'settings') && <button onClick={() => go('/settings')}>设置</button>}
|
||||
</nav>
|
||||
{route === '/settings' && canPage(init, 'settings') ? <SettingsPage /> : <HomePage />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
@@ -0,0 +1,57 @@
|
||||
// portal-sdk 浏览器全局封装:vendor/portal-sdk.js 经 classic <script> 注入
|
||||
// createPortalClient,应用源码不打包 SDK(保证与宿主桥协议版本同源)。
|
||||
// 主题自管——不读握手 payload 里的 theme 硬编码。
|
||||
// acl(admin→{bypass:true};member→仅 defaultForMember 页的 PID 列表)驱动页面可见性/
|
||||
// 路由守卫——只是 UX 闸,真闸是后端四道闸。
|
||||
// 占位:全局替换 my-app → 你的 listingKey。
|
||||
export interface AclInit {
|
||||
bypass?: boolean;
|
||||
permissions?: { pid: string; scope?: string }[];
|
||||
}
|
||||
|
||||
export interface InitPayloadLike {
|
||||
user?: { id: string; name: string } | null;
|
||||
route?: string | null;
|
||||
acl?: AclInit;
|
||||
}
|
||||
|
||||
export interface PortalClientLike {
|
||||
ready(): Promise<InitPayloadLike>;
|
||||
getToken(scopes?: string[]): Promise<string>;
|
||||
callService<T = unknown>(
|
||||
name: string,
|
||||
path: string,
|
||||
init?: { method?: string; body?: string; headers?: Record<string, string>; timeoutMs?: number },
|
||||
): Promise<T>;
|
||||
routeSync(path: string): void;
|
||||
setDirty(dirty: boolean): void;
|
||||
}
|
||||
|
||||
const factory = (globalThis as { createPortalClient?: () => PortalClientLike }).createPortalClient;
|
||||
|
||||
// 直接以顶层页面打开(/apps/<key>/_/ 静态托管路径)时 createPortalClient 抛
|
||||
//「must run inside the portal host iframe」——降级为 sdk=null,由 UI 给友好提示。
|
||||
function createClient(): PortalClientLike | null {
|
||||
if (!factory) return null;
|
||||
try {
|
||||
return factory();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const sdk: PortalClientLike | null = createClient();
|
||||
|
||||
// initialRoute:宿主把 App 内部路由放在 iframe URL 的 ?r= 上(刷新可恢复深链)。
|
||||
export function initialRoute(): string {
|
||||
const r = new URLSearchParams(globalThis.location?.search ?? '').get('r');
|
||||
return r && r.startsWith('/') && !r.startsWith('//') ? r : '/';
|
||||
}
|
||||
|
||||
// 页面级可见性判定:pageKey ∈ aclManifest.pages;admin(bypass)全放行。
|
||||
export function canPage(init: InitPayloadLike | null, pageKey: string): boolean {
|
||||
const acl = init?.acl;
|
||||
if (!acl) return true; // 旧宿主无 acl 字段时不设防(真闸在后端)
|
||||
if (acl.bypass === true) return true;
|
||||
return (acl.permissions ?? []).some((p) => p.pid === `my-app:page:${pageKey}`);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["react", "react-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in new issue
Block a user