demo: 加 Vue 前端到 examples/basic-admin/web

为了端到端验证 Shrimp 框架,把 hand-admin 前端拷进 examples/basic-admin/web
作为临时演示用前端,@shrimp alias 直接指向同仓 src/,免构建。

修改要点:
- web/vite.config.ts: @shrimp 改成 ../../../src; proxy /admin-api 到 :3100
- web/tsconfig.json: 同步 @shrimp 路径; 删掉 hand-server-2 的 paths
- web/src/locales: 删掉 matter_*/zerobase 业务 key
- web/src/module: 删掉 matter_*/zerobase 业务 vue,只留 user/role/FieldRelativeTime
- examples/basic-admin/index.ts:
  - mount 到 /admin-api/* 而不是 /api/*
  - 加 /config /login /me 端点 (mock super-admin operator,固定 token)
  - DemoShrimp 子类覆盖 getMenuTree 用模块自身 label
  - MemoryShrimpCrud 的 getRelatedCrud 手动 wire 到 crudRegistry,
    populate 才能跑 (后续 70→90 改进项)

curl 已验证 /config /login /me /product/list 全通,tagIds_label 正确旁挂。
This commit is contained in:
2026-05-23 15:28:10 +08:00
parent 8548a1760a
commit 27a2348815
68 changed files with 7345 additions and 23 deletions
+119 -23
View File
@@ -4,12 +4,18 @@
* 用 MemoryShrimpCrud 跑一个零依赖的 admin server,展示:
* - Shrimp 核心(模块注册 / RPC dispatch / 权限树)
* - ShrimpTableModule + MemoryShrimpCrud 的 CRUD 自动事件
* - 通过 Express 暴露成 HTTP RPC
* - 通过 Express 暴露成完整的 hand-admin 协议:
* GET /admin-api/config
* POST /admin-api/login
* GET /admin-api/me
* POST /admin-api/<module>[/<sub>...]/<event>
*
* 不使用 ShrimpServer/Mongoose/JWT,刻意暴露 ShrimpServer 把太多东西耦合在
* Express+Mongo+Auth 一起的事实,作为后续重构的对照样本。
*
* 单一 mock super-admin operator,前端登录直接放行,token 是固定字符串。
*/
import express from "express"
import express, { type Request, type Response } from "express"
import {
Shrimp,
ShrimpTableModule,
@@ -18,7 +24,25 @@ import {
ShrimpError,
ShrimpCode,
} from "shrimp-server"
import type { IFieldConfig, WithId } from "shrimp-server"
import type {
IFieldConfig,
WithId,
IShrimpOperator,
IModuleInfo,
IMenuItem,
IMeResult,
} from "shrimp-server"
class DemoShrimp extends Shrimp {
/** 默认 getMenuTree 用 i18n key,demo 没翻译这些键,直接用模块自带的 label */
getMenuTree(): IMenuItem[] {
const items: IMenuItem[] = []
this.modules.forEach(m => {
items.push({ name: m.moduleKey, label: m.label, moduleKey: m.moduleKey })
})
return items
}
}
interface ITag { _id: string; name: string }
interface IProduct { _id: string; code: string; name: string; price: number; tagIds: string[] }
@@ -37,13 +61,27 @@ const productFields: IFieldConfig[] = [
},
]
async function bootstrap() {
const shrimp = new Shrimp()
const FAKE_TOKEN = "demo-super-admin-token"
const superAdmin: IShrimpOperator = {
userId: "demo-admin",
roleId: ["demo-super"],
isSuperAdmin: true,
}
async function bootstrap() {
const shrimp = new DemoShrimp()
// TODO(70→90): ShrimpTableModule 当前只在 dataSource.model 存在时自动登记 crudRegistry,
// 这里需要手动 wire 让 MemoryShrimpCrud 的 populate 也能查到关联表
const tagCrud = new MemoryShrimpCrud<WithId<ITag>>()
tagCrud.getRelatedCrud = (k) => shrimp.crudRegistry.get(k)
shrimp.crudRegistry.set("tag", tagCrud)
new ShrimpTableModule(shrimp, "tag", tagCrud, tagFields, "标签管理").initCrud()
const productCrud = new MemoryShrimpCrud<WithId<IProduct>>()
productCrud.getRelatedCrud = (k) => shrimp.crudRegistry.get(k)
shrimp.crudRegistry.set("product", productCrud)
new ShrimpTableModule(shrimp, "product", productCrud, productFields, "商品管理").initCrud()
// 种子数据
@@ -52,34 +90,92 @@ async function bootstrap() {
await productCrud.add({ code: "P001", name: "苹果", price: 5, tagIds: [t1._id] } as any)
await productCrud.add({ code: "P002", name: "香蕉", price: 3, tagIds: [t1._id, t2._id] } as any)
// super admin 看得到所有模块
const allKeys = (await shrimp.allPower()).map(p => p.key)
const superPowers = new Set(allKeys)
const app = express()
app.use(express.json())
// 简化的 RPC dispatch: POST /api/<...path> body 是参数数组
app.post(/^\/api\/.+/, async (req, res) => {
const path = req.path.replace(/^\/api\//, "").split("/").filter(Boolean)
const args = Array.isArray(req.body) ? req.body : []
try {
const data = await shrimp.call(path, ...args)
res.json({ code: ShrimpCode.OK, data })
} catch (e: any) {
if (e instanceof ShrimpError) {
res.json({ code: e.code, tip: e.tip })
} else {
console.error(e)
res.json({ code: ShrimpCode.SERVER_ERROR, log: String(e?.message || e) })
}
// 简单认证: 仅检查 Authorization,通过即注入 super-admin operator
function authedRunner(req: Request, res: Response, run: () => Promise<any>) {
const auth = req.headers.authorization || ""
const token = auth.replace(/^Bearer\s+/i, "")
if (token !== FAKE_TOKEN) {
res.json({ code: ShrimpCode.UNAUTHORIZED, tip: { t: "shrimp.relogin" } })
return
}
Shrimp.asyncLocalStorage.run(superAdmin, async () => {
try {
const data = await run()
res.json({ code: ShrimpCode.OK, data })
} catch (e: any) {
if (e instanceof ShrimpError) {
res.json({ code: e.code, tip: e.tip })
} else {
console.error(e)
res.json({ code: ShrimpCode.SERVER_ERROR, log: String(e?.message || e) })
}
}
})
}
// --- 公开接口 ---
app.get("/admin-api/config", (_req, res) => {
res.json({ code: ShrimpCode.OK, data: { title: "Shrimp Basic Admin Demo" } })
})
app.post("/admin-api/login", (req, res) => {
const { username, password } = req.body || {}
// demo 模式不校验密码,任意账号都给同一个 token
if (!username || !password) {
res.json({ code: ShrimpCode.UNAUTHORIZED, tip: { t: "shrimp.loginFailed" } })
return
}
res.json({ code: ShrimpCode.OK, data: { token: FAKE_TOKEN } })
})
// --- 需要登录的接口 ---
app.get("/admin-api/me", (req, res) => {
authedRunner(req, res, async () => {
const moduleInfo: IModuleInfo[] = shrimp.getModuleInfo(superPowers)
const fullMenu = shrimp.getMenuTree()
const menuTree: IMenuItem[] = shrimp.filterMenuTree(fullMenu, allKeys)
const result: IMeResult = {
nickname: "Demo Admin",
moduleInfo,
menuTree,
options: {},
}
return result
})
})
// RPC dispatch: POST /admin-api/<...path> body 是参数数组
app.post(/^\/admin-api\/.+/, (req, res) => {
const rawPath = req.path.replace(/^\/admin-api\//, "")
// 不要把 login/config 也接进来
if (rawPath === "login" || rawPath === "config" || rawPath === "me") {
res.status(404).end()
return
}
authedRunner(req, res, async () => {
const path = rawPath.split("/").filter(Boolean)
const args = Array.isArray(req.body) ? req.body : []
return shrimp.call(path, ...args)
})
})
const port = 3100
app.listen(port, () => {
console.log(`Shrimp basic-admin demo: http://localhost:${port}`)
console.log("")
console.log("# 试试:")
console.log(` curl -s -X POST http://localhost:${port}/api/product/list -H 'content-type: application/json' -d '[{}]' | jq`)
console.log(` curl -s -X POST http://localhost:${port}/api/tag/list -H 'content-type: application/json' -d '[{}]' | jq`)
console.log(` curl -s -X POST http://localhost:${port}/api/product/add -H 'content-type: application/json' -d '[{"code":"P003","name":"橙子","price":4,"tagIds":[]}]' | jq`)
console.log("# 配合前端: cd web && npm install && npm run dev,然后访问 http://localhost:5173")
console.log("# 登录: 任意账号密码")
console.log("")
console.log("# 也可以直接 curl:")
console.log(` TOKEN=${FAKE_TOKEN}`)
console.log(` curl -s -X POST http://localhost:${port}/admin-api/product/list -H 'content-type: application/json' -H "Authorization: Bearer $TOKEN" -d '[{}]' | jq`)
})
}