feat(dev-app): update nav and icon contract to latest manifest spec — footerNavItems field, sidebar top/bottom nav semantics, lucide/emoji icon resolution with silent-fallback warning; templates gain footerNavItems examples and fix stale 'home' icon (renamed to 'house' in lucide 0.469); check-app.sh validates footerNavItems in both node and python3 branches
This commit is contained in:
1 parent
ab0bfc38e1
commit
4e34ed0c0c
6 files changed
+40
-7
No files matched your search
@@ -10,7 +10,7 @@ TickShield 生态应用的交付物只有两样:**镜像 + `app.manifest.json`
|
|||||||
- **新建模式**:仓库没有 `app.manifest.json` / `Dockerfile` —— 交互采集四要素 → 照 `references/templates/` 生成骨架
|
- **新建模式**:仓库没有 `app.manifest.json` / `Dockerfile` —— 交互采集四要素 → 照 `references/templates/` 生成骨架
|
||||||
- **审查模式**:仓库已有接入痕迹 —— 对照 `references/checklist.md` 逐项审计 → 出修正清单
|
- **审查模式**:仓库已有接入痕迹 —— 对照 `references/checklist.md` 逐项审计 → 出修正清单
|
||||||
|
|
||||||
契约锚点:本 skill 基线 = TickShield 平台 v4.0 + APPSTORE-RUNTIME + FR-101 registries 白名单(2026-10)。与平台冲突时,以平台仓 `docs/reference/应用开发与上架指引.md` 和 `apps/portal/internal/modules/registry/manifest.go` 的校验代码为准,并回本仓库更新本 skill。`docs/reference/App出仓与集成指引.md`、`App开发者指南.html` 是**已废弃的历史文档**(@xgent 旧体系),禁止照搬。
|
契约锚点:本 skill 基线 = TickShield 平台 v4.0 + APPSTORE-RUNTIME + FR-101 registries 白名单 + FR-102/TKT-557 导航与图标规范(2026-10)。与平台冲突时,以平台仓 `docs/reference/应用开发与上架指引.md` 和 `apps/portal/internal/modules/registry/manifest.go` 的校验代码为准,并回本仓库更新本 skill。`docs/reference/App出仓与集成指引.md`、`App开发者指南.html` 是**已废弃的历史文档**(@xgent 旧体系),禁止照搬。
|
||||||
|
|
||||||
## 总流程
|
## 总流程
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
| A5 | `scopes` 每项命名空间格式(≥两段,建议 `<listingKey>.<action>`) | die `[S]` | 正则 |
|
| A5 | `scopes` 每项命名空间格式(≥两段,建议 `<listingKey>.<action>`) | die `[S]` | 正则 |
|
||||||
| A6 | manifest **不含** `deployDescriptor` | die `[S]` | 无该 key |
|
| A6 | manifest **不含** `deployDescriptor` | die `[S]` | 无该 key |
|
||||||
| A7 | `exchangeTargets`/`dependencies` 各项是合法 listingKey | die | 正则 |
|
| A7 | `exchangeTargets`/`dependencies` 各项是合法 listingKey | die | 正则 |
|
||||||
| A8 | `navItems` 每项含 `id/label/path`;`aclManifest.pages` 含 `key/label`,`audience` ∈ admin/viewer/all | die | 对照契约表 |
|
| A8 | `navItems`/`footerNavItems` 每项含 `id/label/path`;`icon` 为 emoji/字符或 lucide 名(写错名静默兜底成字符,须眼见为实);`aclManifest.pages` 含 `key/label`,`audience` ∈ admin/viewer/all | die `[S]` | 对照契约表 §2.1 |
|
||||||
| A9 | `color` 为 `#RRGGBB`;`icon`≤64 / `cat`≤64 / `tagline`≤120 / `desc`≤2000 | warn `[S]` | 长度/格式 |
|
| A9 | `color` 为 `#RRGGBB`;`icon`≤64 / `cat`≤64 / `tagline`≤120 / `desc`≤2000 | warn `[S]` | 长度/格式 |
|
||||||
| A10 | 治理字段变更知悉审核语义(202 进队列,批准前不生效) | log | 口头/文档确认 |
|
| A10 | 治理字段变更知悉审核语义(202 进队列,批准前不生效) | log | 口头/文档确认 |
|
||||||
|
|
||||||
|
|||||||
@@ -20,23 +20,38 @@
|
|||||||
| `name` | 是 | 非空纯字符串(不支持多语言对象) |
|
| `name` | 是 | 非空纯字符串(不支持多语言对象) |
|
||||||
| `version` | 是 | 严格 semver(含 prerelease/build 段) |
|
| `version` | 是 | 严格 semver(含 prerelease/build 段) |
|
||||||
| `type` | 否 | `micro`|`service`,缺省 = `service` |
|
| `type` | 否 | `micro`|`service`,缺省 = `service` |
|
||||||
| `icon` | 否 | ≤64 字符 |
|
| `icon` | 否 | ≤64 字符;emoji/字符 或 lucide 图标名(语义见 §2.1) |
|
||||||
| `color` | 否 | `^#[0-9A-Fa-f]{6}$` |
|
| `color` | 否 | `^#[0-9A-Fa-f]{6}$`(同时作应用图标底色) |
|
||||||
| `cat` | 否 | ≤64 字符 |
|
| `cat` | 否 | ≤64 字符 |
|
||||||
| `tagline` | 否 | ≤120 字符 |
|
| `tagline` | 否 | ≤120 字符 |
|
||||||
| `desc` | 否 | ≤2000 字符 |
|
| `desc` | 否 | ≤2000 字符 |
|
||||||
| `scopes` | 否 | 每项 `^[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)+$`(至少两段,如 `my-app.read`) |
|
| `scopes` | 否 | 每项 `^[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)+$`(至少两段,如 `my-app.read`) |
|
||||||
| `exchangeTargets` | 否 | 每项必须是合法 listingKey;声明本应用可 token-exchange 的目标 |
|
| `exchangeTargets` | 否 | 每项必须是合法 listingKey;声明本应用可 token-exchange 的目标 |
|
||||||
| `dependencies` | 否 | 每项必须是合法 listingKey |
|
| `dependencies` | 否 | 每项必须是合法 listingKey |
|
||||||
| `navItems` | 否 | 每项 `id`/`label`/`path` 必填,`icon` 可选 |
|
| `navItems` | 否 | 顶部/主导航项:每项 `id`/`label`/`path` 必填,`icon` 可选(语义见 §2.1) |
|
||||||
|
| `footerNavItems` | 否 | 底部导航项(TKT-557):结构与校验同 `navItems`(语义见 §2.1) |
|
||||||
| `dashboard.widgets` | 否 | JSON key 字面量带个点;每项 `key`/`type` 必填 |
|
| `dashboard.widgets` | 否 | JSON key 字面量带个点;每项 `key`/`type` 必填 |
|
||||||
| `aclManifest` | 否 | `{pages:[{key,label,audience,defaultForMember,actions:[{key,label}]}]}`;`audience` ∈ `admin`/`viewer`/`all`(缺省);`defaultForMember` 仅为声明性标记(零绑定=零权限,不参与默认补足) |
|
| `aclManifest` | 否 | `{pages:[{key,label,audience,defaultForMember,actions:[{key,label}]}]}`;`audience` ∈ `admin`/`viewer`/`all`(缺省);`defaultForMember` 仅为声明性标记(零绑定=零权限,不参与默认补足) |
|
||||||
| `deployDescriptor` | 否 | **仅平台管理员可写**;应用仓库不携带(自注册时被剥离,submit 非管理员携带 → 403) |
|
| `deployDescriptor` | 否 | **仅平台管理员可写**;应用仓库不携带(自注册时被剥离,submit 非管理员携带 → 403) |
|
||||||
|
|
||||||
**治理字段** = `scopes`/`aclManifest`/`dependencies`/`exchangeTargets`:集合语义比较(忽略顺序),有变更 → 202 进审核队列,批准前不生效、不影响已安装租户;**首个版本直接生效**(无基线可 diff)。展示元信息与 version 不算治理字段,直接生效。
|
**治理字段** = `scopes`/`aclManifest`/`dependencies`/`exchangeTargets`:集合语义比较(忽略顺序),有变更 → 202 进审核队列,批准前不生效、不影响已安装租户;**首个版本直接生效**(无基线可 diff)。展示元信息、`navItems`/`footerNavItems` 与 version 不算治理字段,直接生效。
|
||||||
|
|
||||||
PID 权限点由 aclManifest 派生:页面 `<listingKey>:page:<key>`、动作 `<listingKey>:action:<key>`,可带 `:read`/`:write` 档。
|
PID 权限点由 aclManifest 派生:页面 `<listingKey>:page:<key>`、动作 `<listingKey>:action:<key>`,可带 `:read`/`:write` 档。
|
||||||
|
|
||||||
|
### 2.1 导航与图标(FR-102 / TKT-557)
|
||||||
|
|
||||||
|
应用只在 manifest **声明**导航项;编排权在租户(平台组在 console 租户详情「自定义导航菜单」配置,落 `tenant_nav_layouts`),布局只是组织方式、不授权——可见性恒由「已授权 ∧ 已上架 ∧ 安装启用 ∧ pool 求交」决定。
|
||||||
|
|
||||||
|
- **`navItems`(顶部/主导航)**:渲染在侧边栏「应用导航」段应用条目之下,深链 `/apps/<key><path>`(`path` 以 `/` 开头、指应用内路由)。`id` 保持稳定——改 id ≡ 删旧菜单加新菜单,租户布局里的引用随之失效。租户可把项编进分组(groups 平铺 / accordion 折叠,≤30 组、组内 ≤30 项);未进布局的应用/项不在侧边栏出现,但 `/apps` 应用中心与 ⌘K 仍可达。
|
||||||
|
- **`footerNavItems`(底部导航)**:渲染在侧边栏底部钉住段,三态——租户布局无 `footer` 键(缺省)→ 全部可见应用的 footerNavItems 平铺;布局 `footer` 有序列表编排(≤30 项、footer 内 (app,nav) 去重);显式 `footer: []` → 底部段整块不渲染。
|
||||||
|
- service 应用同样可声明两者:无嵌入页,入口落 `/apps/:key` 详情信息卡(基准示例见平台仓 `apps/demo-app/app.manifest.json` 的 footerNavItems)。商店详情 Drawer「导航」tab 分两段展示 navItems / footerNavItems。
|
||||||
|
|
||||||
|
**图标 `icon`**(应用级与导航项同一渲染口径):
|
||||||
|
|
||||||
|
- 三种形态:**emoji/Unicode 字符** → 原样字符渲染;**ASCII 名** → 归一化 kebab-case 后按 lucide 图标名懒加载解析(如 `house`、`settings`、`circle-help`,可用集合以门户 lucide-react 版本为准);**空** → 兜底。
|
||||||
|
- ⚠️ **写错 lucide 名不报错,静默兜底为字符渲染**(把名字原文显示在侧边栏)——图标名务必眼见为实。lucide 改名先例:0.469 起 `home` 更名 `house`,旧名直接漏出 "home" 文字。
|
||||||
|
- 生效优先级:**租户布局覆盖 icon ?? manifest icon ?? 默认兜底**(主导航项 = FileText;底部段行 = 应用图标;应用级 = listingKey 派生彩色首字母方块,`color` 作底色)。布局覆盖值限 emoji/字符(≤8 字符)。
|
||||||
|
|
||||||
## 3. 注册 / 心跳 / 下线(service 型容器)
|
## 3. 注册 / 心跳 / 下线(service 型容器)
|
||||||
|
|
||||||
端点(均走 `X-App-Key` + `X-App-Secret` 凭证头,bcrypt 比对;同 IP+key 10min 连错 10 次锁 429):
|
端点(均走 `X-App-Key` + `X-App-Secret` 凭证头,bcrypt 比对;同 IP+key 10min 连错 10 次锁 429):
|
||||||
|
|||||||
@@ -11,9 +11,12 @@
|
|||||||
"scopes": ["my-app.read", "my-app.write"],
|
"scopes": ["my-app.read", "my-app.write"],
|
||||||
"exchangeTargets": [],
|
"exchangeTargets": [],
|
||||||
"navItems": [
|
"navItems": [
|
||||||
{ "id": "home", "label": "首页", "path": "/", "icon": "home" },
|
{ "id": "home", "label": "首页", "path": "/", "icon": "house" },
|
||||||
{ "id": "settings", "label": "设置", "path": "/settings", "icon": "settings" }
|
{ "id": "settings", "label": "设置", "path": "/settings", "icon": "settings" }
|
||||||
],
|
],
|
||||||
|
"footerNavItems": [
|
||||||
|
{ "id": "help", "label": "帮助", "path": "/help", "icon": "circle-help" }
|
||||||
|
],
|
||||||
"dashboard.widgets": [],
|
"dashboard.widgets": [],
|
||||||
"aclManifest": {
|
"aclManifest": {
|
||||||
"pages": [
|
"pages": [
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
"scopes": ["my-app.read", "my-app.write"],
|
"scopes": ["my-app.read", "my-app.write"],
|
||||||
"exchangeTargets": [],
|
"exchangeTargets": [],
|
||||||
"navItems": [],
|
"navItems": [],
|
||||||
|
"footerNavItems": [
|
||||||
|
{ "id": "docs", "label": "接入文档", "path": "/docs", "icon": "📖" }
|
||||||
|
],
|
||||||
"dashboard.widgets": [],
|
"dashboard.widgets": [],
|
||||||
"aclManifest": {
|
"aclManifest": {
|
||||||
"pages": [
|
"pages": [
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ if (o.navItems !== undefined) {
|
|||||||
if (!Array.isArray(o.navItems)) console.log('BAD_NAV=not-array');
|
if (!Array.isArray(o.navItems)) console.log('BAD_NAV=not-array');
|
||||||
else o.navItems.forEach((it, i) => { if (!it || !it.id || !it.label || !it.path) console.log('BAD_NAV=' + i); });
|
else o.navItems.forEach((it, i) => { if (!it || !it.id || !it.label || !it.path) console.log('BAD_NAV=' + i); });
|
||||||
}
|
}
|
||||||
|
if (o.footerNavItems !== undefined) {
|
||||||
|
if (!Array.isArray(o.footerNavItems)) console.log('BAD_FNAV=not-array');
|
||||||
|
else o.footerNavItems.forEach((it, i) => { if (!it || !it.id || !it.label || !it.path) console.log('BAD_FNAV=' + i); });
|
||||||
|
}
|
||||||
const pages = o.aclManifest === undefined ? undefined : o.aclManifest?.pages;
|
const pages = o.aclManifest === undefined ? undefined : o.aclManifest?.pages;
|
||||||
if (pages !== undefined) {
|
if (pages !== undefined) {
|
||||||
if (!Array.isArray(pages)) console.log('BAD_ACL=pages-not-array');
|
if (!Array.isArray(pages)) console.log('BAD_ACL=pages-not-array');
|
||||||
@@ -89,6 +93,13 @@ if nav is not None:
|
|||||||
for i, it in enumerate(nav):
|
for i, it in enumerate(nav):
|
||||||
if not isinstance(it, dict) or not it.get('id') or not it.get('label') or not it.get('path'):
|
if not isinstance(it, dict) or not it.get('id') or not it.get('label') or not it.get('path'):
|
||||||
print(f'BAD_NAV={i}')
|
print(f'BAD_NAV={i}')
|
||||||
|
fnav = o.get('footerNavItems')
|
||||||
|
if fnav is not None:
|
||||||
|
if not isinstance(fnav, list): print('BAD_FNAV=not-array')
|
||||||
|
else:
|
||||||
|
for i, it in enumerate(fnav):
|
||||||
|
if not isinstance(it, dict) or not it.get('id') or not it.get('label') or not it.get('path'):
|
||||||
|
print(f'BAD_FNAV={i}')
|
||||||
acl = o.get('aclManifest')
|
acl = o.get('aclManifest')
|
||||||
if acl is not None:
|
if acl is not None:
|
||||||
pages = acl.get('pages') if isinstance(acl, dict) else None
|
pages = acl.get('pages') if isinstance(acl, dict) else None
|
||||||
@@ -127,6 +138,7 @@ EOF
|
|||||||
grep -q '^BAD_EXCHANGE=' "$TMP" && die "exchangeTargets 含非法 listingKey:$(grep '^BAD_EXCHANGE=' "$TMP" | sed 's/^BAD_EXCHANGE=//' | tr '\n' ' ')"
|
grep -q '^BAD_EXCHANGE=' "$TMP" && die "exchangeTargets 含非法 listingKey:$(grep '^BAD_EXCHANGE=' "$TMP" | sed 's/^BAD_EXCHANGE=//' | tr '\n' ' ')"
|
||||||
grep -q '^BAD_DEPS=' "$TMP" && die "dependencies 含非法 listingKey:$(grep '^BAD_DEPS=' "$TMP" | sed 's/^BAD_DEPS=//' | tr '\n' ' ')"
|
grep -q '^BAD_DEPS=' "$TMP" && die "dependencies 含非法 listingKey:$(grep '^BAD_DEPS=' "$TMP" | sed 's/^BAD_DEPS=//' | tr '\n' ' ')"
|
||||||
grep -q '^BAD_NAV=' "$TMP" && die "navItems 缺 id/label/path:$(grep '^BAD_NAV=' "$TMP" | sed 's/^BAD_NAV=//' | tr '\n' ' ')"
|
grep -q '^BAD_NAV=' "$TMP" && die "navItems 缺 id/label/path:$(grep '^BAD_NAV=' "$TMP" | sed 's/^BAD_NAV=//' | tr '\n' ' ')"
|
||||||
|
grep -q '^BAD_FNAV=' "$TMP" && die "footerNavItems 缺 id/label/path:$(grep '^BAD_FNAV=' "$TMP" | sed 's/^BAD_FNAV=//' | tr '\n' ' ')"
|
||||||
grep -q '^BAD_ACL=' "$TMP" && die "aclManifest.pages 缺 key/label 或 pages 非数组"
|
grep -q '^BAD_ACL=' "$TMP" && die "aclManifest.pages 缺 key/label 或 pages 非数组"
|
||||||
grep -q '^BAD_ACL_AUD=' "$TMP" && die "aclManifest audience 非法(仅 admin|viewer|all)"
|
grep -q '^BAD_ACL_AUD=' "$TMP" && die "aclManifest audience 非法(仅 admin|viewer|all)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in new issue
Block a user