chore: 删除 examples/ — git+http install 会把它一起拖给消费方

包内自带的 Vue demo 不是 shrimp 框架的一部分,git+http 安装时
`files: [...]` 不生效,examples/(尤其 web/ Vue 子项目)会进消费方
node_modules,污染 install 体积并触发 tsc 噪音。

完整接入示例去 hand-server-2 主项目看;basic-admin 那种零依赖
demo 可以以后单独 pin 在 docs/ 或独立仓库。
This commit is contained in:
2026-05-23 16:54:58 +08:00
parent 8e4d5620b7
commit 36326ac26b
76 changed files with 4 additions and 9016 deletions
+1 -19
View File
@@ -2,16 +2,6 @@
声明式字段驱动的 Node.js 后台管理框架(Express + Mongoose + JWT)。后台只声明"表 + 字段",前端自动渲染表格、表单、搜索、菜单。
## 快速跑通(不需要 MongoDB)
```sh
git clone http://git.zeromvc.com/wc24/shrimp-server.git
cd shrimp-server
npm install
cd examples/basic-admin && npm install && npm start
# 另一窗口 curl http://localhost:3100/api/product/list -X POST -d '[{}]' -H 'content-type: application/json'
```
## 在你的项目里用
```sh
@@ -611,15 +601,7 @@ class MyAdmin extends ShrimpServer {
---
## 14. 完整接入示例
参考本仓库:
- [examples/basic-admin/index.ts](examples/basic-admin/index.ts) — 零 Mongo 依赖,用 MemoryShrimpCrud 跑起一个 admin
- [examples/basic-admin/README.md](examples/basic-admin/README.md) — curl 示例 + 跑法
---
## 15. 常见踩坑
## 14. 常见踩坑
- **`relation.moduleKey` 必须能在 crudRegistry 找到。** 如果改成了非默认数据源,确认 `(dataSource as any).model` 存在,否则自动 populate 不生效。
- **`searchable` 只对字符串型字段生效。** 关键词搜索走 LIKE,数字/布尔字段加了也没用,要做精确筛选用 `filterable`。
-52
View File
@@ -1,52 +0,0 @@
# basic-admin demo
零外部依赖的 Shrimp demo,**只用内存 CRUD,不依赖 MongoDB**。
证明:Shrimp 的核心(模块注册 / RPC dispatch / 字段配置)在没有 mongoose、没有 JWT、没有用户表的情况下也能跑。
## 起服务
从 demo 目录起(本地链 shrimp-server):
```sh
cd examples/basic-admin
npm install
npm start
```
或者从仓库根目录直接 `npm run demo`(由根 package.json 提供)。
服务起在 `http://localhost:3100`。
## 试一下
```sh
# 列商品
curl -s -X POST http://localhost:3100/api/product/list \
-H 'content-type: application/json' -d '[{}]' | jq
# 列标签
curl -s -X POST http://localhost:3100/api/tag/list \
-H 'content-type: application/json' -d '[{}]' | jq
# 新增商品
curl -s -X POST http://localhost:3100/api/product/add \
-H 'content-type: application/json' \
-d '[{"code":"P003","name":"橙子","price":4,"tagIds":[]}]' | jq
# 改某个商品
curl -s -X POST http://localhost:3100/api/product/edit \
-H 'content-type: application/json' -d '["1",{"price":99}]' | jq
# 删除商品
curl -s -X POST http://localhost:3100/api/product/delete \
-H 'content-type: application/json' -d '["1"]' | jq
```
请求体永远是参数数组,按位置传给事件 handler。
## 这个 demo 暴露了哪些 70→90 的问题
- **`Shrimp.ts:322` 的 `if ((dataSource as any).model)`** 让 MemoryShrimpCrud 不能注册到 `crudRegistry`,relation populate 自动联表对内存数据源不生效
- **没有现成的 RPC mount helper** — 这段 `app.post("/api/*splat", ...)` 应该是框架提供的,而不是 demo 里手写
- **要登录 / 鉴权要么自己写,要么用 ShrimpServer 强吃 Mongo+JWT**,中间没有"我只想要 RPC + 模块,自己接 auth"这条路径
-168
View File
@@ -1,168 +0,0 @@
/**
* Shrimp basic-admin demo
*
* 用 MemoryShrimpCrud 跑一个零依赖的 admin server,展示:
* - Shrimp 核心(模块注册 / RPC dispatch / 权限树)
* - ShrimpTableModule + MemoryShrimpCrud 的 CRUD 自动事件
* - 通过 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, { type Request, type Response } from "express"
import {
Shrimp,
ShrimpTableModule,
MemoryShrimpCrud,
ShrimpFieldTypes,
ShrimpError,
ShrimpCode,
} from "shrimp-server"
import type {
IFieldConfig,
WithId,
IShrimpOperator,
IModuleInfo,
IMenuItem,
IMeResult,
} from "shrimp-server"
interface ITag { _id: string; name: string }
interface IProduct { _id: string; code: string; name: string; price: number; tagIds: string[] }
const tagFields: IFieldConfig[] = [
{ key: "name", label: "标签名", type: ShrimpFieldTypes.String, required: true, searchable: true },
]
const productFields: IFieldConfig[] = [
{ key: "code", label: "编码", type: ShrimpFieldTypes.String, required: true, editable: "addOnly" },
{ key: "name", label: "名称", type: ShrimpFieldTypes.String, required: true, searchable: true },
{ key: "price", label: "价格", type: ShrimpFieldTypes.Number, required: true },
{
key: "tagIds", label: "标签", type: ShrimpFieldTypes.Relation,
relation: { moduleKey: "tag", labelKey: "name" }, multiple: true, defaultValue: [],
},
]
const FAKE_TOKEN = "demo-super-admin-token"
const superAdmin: IShrimpOperator = {
userId: "demo-admin",
roleId: ["demo-super"],
isSuperAdmin: true,
}
async function bootstrap() {
const shrimp = new Shrimp()
const tagCrud = new MemoryShrimpCrud<WithId<ITag>>()
new ShrimpTableModule(shrimp, "tag", tagCrud, tagFields, "标签管理").initCrud()
const productCrud = new MemoryShrimpCrud<WithId<IProduct>>()
new ShrimpTableModule(shrimp, "product", productCrud, productFields, "商品管理").initCrud()
// 种子数据
const t1 = await tagCrud.add({ name: "热门" } as any)
const t2 = await tagCrud.add({ name: "促销" } as any)
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())
// 简单认证: 仅检查 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("# 配合前端: 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`)
})
}
bootstrap().catch(err => {
console.error("bootstrap failed:", err)
process.exit(1)
})
File diff suppressed because it is too large Load Diff
-17
View File
@@ -1,17 +0,0 @@
{
"name": "shrimp-basic-admin-example",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "tsx index.ts"
},
"dependencies": {
"shrimp-server": "file:../..",
"express": "^4.19.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html >
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
</head>
<body>
<div id="app" class="aminui">
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1,27 +0,0 @@
{
"name": "shrimp-basic-admin-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"axios": "1.3.4",
"element-plus": "^2.10.4",
"vue": "^3.3.4",
"vue-i18n": "^9.1.9",
"vue-router": "4.1.6"
},
"devDependencies": {
"@types/node": "^20.8.3",
"@vitejs/plugin-vue": "^4.2.3",
"sass": "^1.89.2",
"typescript": "^5.0.2",
"vite": "^4.4.5",
"vue-tsc": "^1.8.5"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 B

@@ -1,372 +0,0 @@
/**
* ShrimpClient — Shrimp 框架前端基类
* 框架无关层,包含协议通信、模块管理、菜单树操作
*/
import { ShrimpCode, ShrimpRequestFn, IShrimpResponse, IModuleInfo, IMenuItem, ILoginResult, IMeResult, IShrimpClient, ILabel } from '@shrimp/IShrimpType'
/** tip 展示接口(由框架层实现,如 Element Plus)。msg 是 ILabel,实现层负责翻译后展示 */
export interface IShrimpTipper {
success(msg: ILabel): void
error(msg: ILabel): void
alert(msg: ILabel): Promise<any>
}
/** 路由注册信息,子类 addRoute 的参数 */
export interface IRouteInfo {
moduleKey: string
name: string
path?: string
label: ILabel
events: string[]
/** 组件相对 module 目录的路径(不含 .vue),不传则默认 moduleKey。用于子模块嵌套目录定位 */
componentPath?: string
/** 子模块跳转按钮图标(element-plus icon 名,仅 sub 模块有意义) */
icon?: string
}
class ShrimpResponseError extends Error {
constructor(public data: IShrimpResponse) {
super(data.code.toString())
this.name = 'ShrimpResponseError'
}
}
/**
* 处理 Shrimp 响应中的 tip 和 log
* @returns 处理后的响应数据,或抛出错误
*/
export function handleShrimpResponse(
body: IShrimpResponse,
tipper: IShrimpTipper,
onUnauthorized?: () => void,
): any {
if (body.log) {
console.warn('[Shrimp]', body.log)
}
if (body.code === ShrimpCode.OK) {
if (body.tip) tipper.success(body.tip)
return body.data
}
// 业务错误:根据 code 决定展示方式
if (body.tip) {
if (body.code === ShrimpCode.UNAUTHORIZED) {
tipper.alert(body.tip).then(() => onUnauthorized?.())
} else {
tipper.error(body.tip)
}
} else {
tipper.alert("").then(() => onUnauthorized?.())
// tipper.error(t('base.fail'))
}
const err = new ShrimpResponseError(body) as any
err.code = body.code
err.handled = true
throw err
}
/**
* 根据协议类型生成类型安全的 API 调用器
* 用法:
* const userApi = getCaller<IUserProto>(request, "user")
* const subApi = getCaller<ISubProto>(request, ["parent", "sub"])
*/
export function getCaller<P extends Record<string, any>>(request: ShrimpRequestFn, moduleKey: string | string[]): P {
const segments = Array.isArray(moduleKey) ? moduleKey : [moduleKey]
return new Proxy({} as any, {
get: (target, prop, receiver) => {
if (typeof prop === 'symbol' || prop === 'valueOf' || prop === 'toString') {
return Reflect.get(target, prop, receiver)
}
return (...args: any[]) => request([...segments, prop as string], ...args)
}
})
}
/**
* ShrimpClient 基类
* 封装与 Shrimp 协议相关的公共方法,框架无关
* 业务层(如 HandAdmin)继承此类并实现底层请求
*/
export abstract class ShrimpClient implements IShrimpClient {
/** 当前用户的模块权限列表 */
moduleInfo: IModuleInfo[] = []
/** 当前用户的菜单树(含层级结构) */
menuTree: IMenuItem[] = []
/** 全局选项(/me 返回的 options) */
globalOptions: Record<string, any> = {}
private callers: Map<string, any> = new Map()
/** 子类必须实现:底层 HTTP 请求 */
abstract rawRequest<T = any>(method: string, url: string, data?: any): Promise<T>
/** 子类必须实现:路由注册(框架特定,如 Vue Router) */
abstract registerRoute(info: IRouteInfo): void
// ---- Shrimp RPC ----
/** Shrimp RPC 请求(POST /moduleKey/eventName) */
async doRequest(path: string[], data?: any[]): Promise<IShrimpResponse> {
return this.rawRequest<IShrimpResponse>('POST', '/' + path.join('/'), data)
}
/** 标准 Shrimp 请求函数(过滤 undefined 参数),供 getCaller 使用 */
async shrimpRequest(path: string[], ...args: any[]): Promise<IShrimpResponse> {
const filteredArgs = args.filter(arg => arg !== undefined)
return await this.doRequest(path, filteredArgs.length > 0 ? filteredArgs : undefined)
}
// ---- REST 接口 ----
/** 获取当前用户信息(权限 + 菜单) */
async fetchMe(): Promise<IMeResult> {
return this.rawRequest<IMeResult>('GET', '/me')
}
/** 用户登录 */
async fetchLogin(data: { username: string; password: string }): Promise<ILoginResult> {
return this.rawRequest<ILoginResult>('POST', '/login', data)
}
/** 获取后台配置 */
async fetchConfig(): Promise<Record<string, any>> {
return this.rawRequest<Record<string, any>>('GET', '/config')
}
// ---- 用户信息 ----
/** 获取当前用户信息(moduleInfo + menuTree)并注册路由 */
async loadUserInfo(): Promise<IMeResult> {
const data = await this.fetchMe()
this.moduleInfo = data.moduleInfo
this.menuTree = data.menuTree
this.globalOptions = data.options || {}
if (this.menuTree.length > 0) {
this.registerRoutesFromMenuTree(this.menuTree)
} else {
this.registerRoutesFromModuleInfo(this.moduleInfo)
}
// 子模块永远走 moduleInfo 递归注册(不进 menu)
this.registerSubModuleRoutes(this.moduleInfo)
return data
}
// ---- 路由注册 ----
/**
* 根据 moduleInfo 扁平注册路由
* 适用于无层级菜单的场景:moduleKey 即路由名,path 为 /moduleKey
* 子模块(带 parent)不会作为顶层路由,由 registerSubModuleRoutes 处理
*/
registerRoutesFromModuleInfo(moduleInfo: IModuleInfo[]) {
moduleInfo.forEach(info => {
if (info.parent) return
this.registerRoute({
moduleKey: info.moduleKey,
name: info.moduleKey,
label: info.label,
path: '/' + info.moduleKey,
events: info.events,
})
})
}
/**
* 根据 menuTree 层级注册路由
* 遍历菜单树,对每个带 moduleKey 的项调用 addRoute
*/
registerRoutesFromMenuTree(menuTree: IMenuItem[]) {
const walk = (items: IMenuItem[]) => {
items.forEach(item => {
if (item.moduleKey) {
this.registerRoute({
moduleKey: item.moduleKey,
name: item.name,
path: item.path,
label: item.label,
events: this.getEvents(item.moduleKey),
})
}
if (item.children) {
walk(item.children)
}
})
}
walk(menuTree)
}
/**
* 注册子模块路由(扁平 moduleInfo,通过 parent 链接)
* 子模块路径 = /<parent_path>/<sub>,组件文件 = @/module/<parent_path>/<sub>.vue
*/
registerSubModuleRoutes(moduleInfo: IModuleInfo[]) {
const byKey = new Map(moduleInfo.map(m => [m.moduleKey, m]))
const buildSegments = (info: IModuleInfo): string[] => {
const segs = [info.moduleKey]
let p = info.parent
while (p) {
segs.unshift(p)
p = byKey.get(p)?.parent
}
return segs
}
moduleInfo.forEach(info => {
if (!info.parent) return
const segments = buildSegments(info)
this.registerRoute({
moduleKey: info.moduleKey,
name: segments.join('.'),
path: '/' + segments.join('/'),
label: info.label,
events: info.events,
componentPath: segments.join('/'),
icon: info.icon,
})
})
}
// ---- 菜单树操作 ----
/** 获取第一个菜单路径(用于默认跳转) */
getFirstMenuPath(): string {
const findFirst = (items: IMenuItem[]): string | null => {
for (const item of items) {
if (item.path) return item.path
if (item.moduleKey) return '/' + item.moduleKey
if (item.children) {
const found = findFirst(item.children)
if (found) return found
}
}
return null
}
const path = findFirst(this.menuTree)
if (path) return path
const top = this.moduleInfo.find(m => !m.parent)
return '/' + (top?.moduleKey || '')
}
/** 根据 path 获取所在顶级菜单的索引 */
getMenuIndex(path: string): number {
const findIndex = (items: IMenuItem[], targetPath: string): number => {
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.path === targetPath || '/' + item.name === targetPath) return i
if (item.children) {
const childIndex = findIndex(item.children, targetPath)
if (childIndex !== -1) return i
}
}
return -1
}
return findIndex(this.menuTree, path)
}
/** 判断是否为分组菜单(有 children) */
isGroupMenu(item?: IMenuItem): boolean {
return item != null && item.children != null && item.children.length > 0
}
/** 根据 path 生成面包屑路径(支持子模块: 走 moduleInfo.parent 链) */
getBreadcrumb(path: string): { key: string; label: ILabel; path?: string }[] {
const result: { key: string; label: ILabel; path?: string }[] = []
const findInMenu = (items: IMenuItem[], targetPath: string, out: typeof result): boolean => {
for (const item of items) {
const itemPath = item.path || '/' + item.name
if (itemPath === targetPath) {
out.unshift({ key: item.name, label: item.label, path: itemPath })
return true
}
if (item.children) {
if (findInMenu(item.children, targetPath, out)) {
out.unshift({ key: item.name, label: item.label, path: item.path })
return true
}
}
}
return false
}
if (findInMenu(this.menuTree, path, result)) return result
// 子模块 fallback: 沿 moduleInfo.parent 链构建,带累积路径
const segments = path.split('/').filter(Boolean)
if (segments.length === 0) return result
const byKey = new Map(this.moduleInfo.map(m => [m.moduleKey, m]))
const chain: IModuleInfo[] = []
let info = byKey.get(segments[segments.length - 1])
while (info) {
chain.unshift(info)
info = info.parent ? byKey.get(info.parent) : undefined
}
if (chain.length === 0) return result
// 顶层模块如果在 menu 分组下, 先把分组上下文放进来
findInMenu(this.menuTree, '/' + chain[0].moduleKey, result)
const lastKey = result.length > 0 ? result[result.length - 1].key : ''
let acc = ''
chain.forEach(m => {
acc += '/' + m.moduleKey
if (m.moduleKey === lastKey) return // 已被 menu 添加
result.push({ key: m.moduleKey, label: m.label, path: acc })
})
return result
}
// ---- 模块权限 ----
/** 获取模块的权限事件列表 */
getEvents(moduleKey: string): string[] {
const mod = this.moduleInfo.find(m => m.moduleKey === moduleKey)
return mod?.events || []
}
/** 判断是否有某个事件的权限 */
hasEvent(moduleKey: string, eventName: string): boolean {
return this.getEvents(moduleKey).includes(eventName)
}
/** 获取绑定到指定模块的权限检查器:const can = client.can('shopAdmin'); can('apply') */
can(moduleKey: string): (event: string) => boolean {
return (event: string) => this.hasEvent(moduleKey, event)
}
// ---- 模块 API ----
/** 获取类型安全的模块 API(带缓存,自动沿 parent 链解析嵌套模块路径) */
getModule<P extends Record<string, any>>(moduleKey: string): P {
let caller = this.callers.get(moduleKey)
if (!caller) {
const segments = this.resolveModulePath(moduleKey)
caller = getCaller<P>(this.shrimpRequest.bind(this), segments)
this.callers.set(moduleKey, caller)
}
return caller
}
/** 从 moduleInfo 沿 parent 链回溯出完整路径(根→自身);找不到时退化为单段 */
private resolveModulePath(moduleKey: string): string[] {
const byKey = new Map(this.moduleInfo.map(m => [m.moduleKey, m]))
const info = byKey.get(moduleKey)
if (!info) return [moduleKey]
const segs = [info.moduleKey]
let p = info.parent
while (p) {
segs.unshift(p)
p = byKey.get(p)?.parent
}
return segs
}
/** 清除模块 API 缓存 */
clearModuleCache(moduleKey?: string) {
if (moduleKey) {
this.callers.delete(moduleKey)
} else {
this.callers.clear()
}
}
}
-425
View File
@@ -1,425 +0,0 @@
import * as elIcons from '@element-plus/icons-vue'
import { type App, type Component, defineAsyncComponent, reactive } from 'vue'
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import ElementPlus from 'element-plus'
import { createRouter, createWebHashHistory, Router } from 'vue-router'
import zh_cn from '@/locales/zh-cn.json'
import en from '@/locales/en.json'
import { createI18n, I18n } from 'vue-i18n'
import config from '@/config'
import { IShrimpResponse, ILabel } from '@shrimp/IShrimpType'
import { ShrimpClient, handleShrimpResponse, type IShrimpTipper, type IRouteInfo } from './ShrimpClient'
import FieldString from '@/components/fields/FieldString.vue'
// ============== 字段组件注册表 ==============
const fieldRegistry = new Map<string, Component>()
const fieldOverrides = new Map<string, string>()
// 系统自带字段组件(手动注册)
fieldRegistry.set('string', FieldString)
fieldRegistry.set('boolean', defineAsyncComponent(() => import('@/components/fields/FieldBoolean.vue')))
fieldRegistry.set('date', defineAsyncComponent(() => import('@/components/fields/FieldDate.vue')))
fieldRegistry.set('number', defineAsyncComponent(() => import('@/components/fields/FieldNumber.vue')))
fieldRegistry.set('password', defineAsyncComponent(() => import('@/components/fields/FieldPassword.vue')))
fieldRegistry.set('relation', defineAsyncComponent(() => import('@/components/fields/FieldRelation.vue')))
fieldRegistry.set('select', defineAsyncComponent(() => import('@/components/fields/FieldSelect.vue')))
fieldRegistry.set('textarea', defineAsyncComponent(() => import('@/components/fields/FieldTextarea.vue')))
fieldRegistry.set('json', defineAsyncComponent(() => import('@/components/fields/FieldJson.vue')))
fieldRegistry.set('tab', defineAsyncComponent(() => import('@/components/fields/FieldTab.vue')))
// 业务字段组件(自动加载 module/fields 目录)
const fieldModules = import.meta.glob<Component>('@/module/fields/Field*.vue')
for (const path in fieldModules) {
const match = path.match(/\/Field(.+)\.vue$/)
if (match) {
const name = 'field' + match[1] // FieldGoodsList → fieldGoodsList
fieldRegistry.set(name, defineAsyncComponent(fieldModules[path]))
}
}
// 模块页面组件(支持嵌套,顶层 module/{key}.vue,子模块 module/{parent}/{sub}.vue)
const moduleComponents = import.meta.glob<Component>('@/module/**/*.vue')
function resolveModuleComponent(componentPath: string): (() => Promise<Component>) | null {
const suffix = `/module/${componentPath}.vue`
for (const key in moduleComponents) {
if (key.endsWith(suffix)) return moduleComponents[key]
}
console.warn('[VueAdmin] available module keys:', Object.keys(moduleComponents))
return null
}
/** 注册自定义字段组件 */
export function registerField(type: string, comp: Component) {
fieldRegistry.set(type, comp)
}
/** 为指定模块的指定字段覆盖组件 */
export function overrideField(moduleKey: string, fieldKey: string, comp: Component) {
const registryKey = `__override_${moduleKey}_${fieldKey}`
fieldRegistry.set(registryKey, comp)
fieldOverrides.set(`${moduleKey}.${fieldKey}`, registryKey)
}
/** 根据模块+字段获取对应组件,优先 override,再按 type,最后回退 FieldString */
export function getFieldComponent(moduleKey: string, type: string, fieldKey?: string): Component {
if (fieldKey) {
const override = fieldOverrides.get(`${moduleKey}.${fieldKey}`)
if (override && fieldRegistry.has(override)) return fieldRegistry.get(override)!
}
return fieldRegistry.get(type) || FieldString
}
// ============== LocalData(localStorage 封装) ==============
class LocalData {
set(key: string, data: any) {
localStorage.setItem(key, JSON.stringify(data))
}
get(key: string) {
try {
const value = localStorage.getItem(key)
return value ? JSON.parse(value) : null
} catch {
return null
}
}
remove(key: string) {
localStorage.removeItem(key)
}
clear() {
localStorage.clear()
}
}
// ============== VueAdmin(Vue 整合层) ==============
export class VueAdmin extends ShrimpClient {
router!: Router
localData = new LocalData()
i18n!: I18n
adminConfig: Record<string, any> = reactive({})
userName = ''
// ---- 字段组件注册 ----
/** 注册自定义字段组件 */
registerField(type: string, comp: Component) {
fieldRegistry.set(type, comp)
}
/** 为指定模块的指定字段覆盖组件 */
overrideField(moduleKey: string, fieldKey: string, comp: Component) {
const registryKey = `__override_${moduleKey}_${fieldKey}`
fieldRegistry.set(registryKey, comp)
fieldOverrides.set(`${moduleKey}.${fieldKey}`, registryKey)
}
/** 根据模块+字段获取对应组件 */
getFieldComponent(moduleKey: string, type: string, fieldKey?: string): Component {
if (fieldKey) {
const override = fieldOverrides.get(`${moduleKey}.${fieldKey}`)
if (override && fieldRegistry.has(override)) return fieldRegistry.get(override)!
}
return fieldRegistry.get(type) || FieldString
}
state = reactive({
isMobile: false,
})
// ---- token(跨刷新持久化) ----
private _token: string | null = null
get token() { return this._token }
set token(value: string | null) {
this.localData.set("token", value)
this._token = value
}
langList = [
{ lang: 'zh-cn', name: '简体中文' },
// { lang: 'en', name: 'English' },
]
/** 可用语言数量,<=1 时不需要显示切换按钮 */
get langCount() { return this.langList.length }
// ---- locale(跨刷新持久化) ----
get locale(): string {
return (this.i18n.global.locale as any).value
}
set locale(value: string) {
(this.i18n.global.locale as any).value = value
this.localData.set("lang", value)
}
/** 切换到下一个语言 */
toggleLang() {
if (this.langCount <= 1) return
const idx = this.langList.findIndex(l => l.lang === this.locale)
const next = this.langList[(idx + 1) % this.langCount]
this.locale = next.lang
}
/** 获取当前语言的显示名 */
get currentLangName() {
return this.langList.find(l => l.lang === this.locale)?.name || this.locale
}
// ---- HTTP ----
request = axios.create({
baseURL: config.API_URL,
timeout: config.TIMEOUT
})
constructor() {
super()
this.initHttp()
this.initI18n()
this.initRouter()
this.initMobileCheck()
this._token = this.localData.get("token")
}
// ---- i18n ----
initI18n() {
this.i18n = createI18n({
locale: this.localData.get("lang") || 'zh-cn',
globalInjection: true,
legacy: false,
messages: { 'zh-cn': zh_cn, 'en': en }
})
}
/**
* 通用翻译方法,处理 ILabel union
* - string → 字面量,原样返回
* - { t, params } → vue-i18n t(),params 递归翻译
* - { tc, count } → vue-i18n tc(),复数
* - { d, format } → vue-i18n d(),日期格式化
* - { n, format } → vue-i18n n(),数字格式化
* - { zh, en, ... }→ 内联多语言,按当前 locale 取
*/
t(value?: ILabel | null): string {
if (value == null) return ''
if (typeof value === 'string') return value
const v = value as any
if ('t' in v) return (this.i18n.global.t as any)(v.t, this.recurseParams(v.params))
if ('tc' in v) return ((this.i18n.global as any).t)(v.tc, this.recurseParams(v.params), v.count)
if ('d' in v) return (this.i18n.global.d as any)(v.d, v.format)
if ('n' in v) return (this.i18n.global.n as any)(v.n, v.format)
return v[this.locale] || ''
}
private recurseParams(params?: Record<string, any>): Record<string, any> {
if (!params) return {}
const out: Record<string, any> = {}
for (const [k, val] of Object.entries(params)) {
out[k] = (val !== null && typeof val === 'object') ? this.t(val as ILabel) : val
}
return out
}
// ---- ShrimpClient 抽象方法实现 ----
/** 覆盖:保存用户名和全局选项 */
async loadUserInfo() {
const data = await this.fetchMe()
this.userName = (data as any).nickname || ''
this.moduleInfo = data.moduleInfo
this.menuTree = data.menuTree
this.globalOptions = data.options || {}
if (this.menuTree.length > 0) {
this.registerRoutesFromMenuTree(this.menuTree)
} else {
this.registerRoutesFromModuleInfo(this.moduleInfo)
}
this.registerSubModuleRoutes(this.moduleInfo)
return data
}
/** 底层 HTTP 传输:所有请求都走这里 */
async rawRequest<T = any>(method: string, url: string, data?: any): Promise<T> {
const res = await this.request({ method, url, data })
return res.data as T
}
/** 注册 Vue 路由 */
registerRoute(info: IRouteInfo) {
const componentPath = info.componentPath || info.moduleKey
const importer = resolveModuleComponent(componentPath)
if (!importer) {
console.warn(`[VueAdmin] module component not found: @/module/${componentPath}.vue`)
return
}
this.router.addRoute("layout", {
path: info.path || "/" + info.name,
name: info.name,
component: importer,
props: { moduleKey: info.moduleKey, events: info.events },
meta: { title: info.label }
})
}
// ---- Router ----
initRouter() {
this.router = createRouter({
history: createWebHashHistory(),
routes: [
{
name: "layout",
path: "/",
component: () => import('@/views/layout.vue'),
children: [
]
},
{
path: "/login",
component: () => import('@/views/login.vue'),
meta: { title: "t:login.signIn" }
},
{
path: "/:pathMatch(.*)",
component: () => import('@/views/404.vue'),
},
],
})
this.router.afterEach((to) => {
if (to.meta?.title) {
document.title = this.t(to.meta.title as string)
} else {
document.title = this.adminConfig.title || 'Admin'
}
})
this.router.beforeEach(async (to, _from, next) => {
if (to.path === "/login") {
next()
} else if (!this.token) {
next({ path: '/login' })
} else if (this.moduleInfo.length === 0) {
try {
await this.loadUserInfo()
const firstPath = this.getFirstMenuPath()
next(to.path === "/"
? { path: firstPath, replace: true }
: { ...to, replace: true })
} catch {
// /me 失败(token 过期等),跳登录
this.token = null
next({ path: '/login' })
}
} else {
next()
}
})
}
// ---- Tipper(Shrimp 协议提示接口) ----
tipper: IShrimpTipper = {
success: (msg) => ElMessage.success(this.t(msg)),
error: (msg) => ElMessage.error(this.t(msg)),
alert: (msg) => ElMessageBox.alert(this.t(msg), {
confirmButtonText: (this.i18n.global.t as any)('base.confirm'),
showClose: true,
}),
}
// ---- HTTP 拦截器 ----
initHttp() {
this.request.interceptors.request.use((reqConfig) => {
if (this.token) {
reqConfig.headers.Authorization = `Bearer ${this.token}`
}
return reqConfig
})
this.request.interceptors.response.use(
(res) => {
const body = res.data as IShrimpResponse
// 兼容非 Shrimp 标准格式的接口
if (body.code === undefined) {
return res
}
try {
if (body.tip) {
body.tip = this.t(body.tip)
}
res.data = handleShrimpResponse(body, this.tipper, () => {
this.token = null
this.router.push('/login')
})
return res
} catch (err) {
return Promise.reject(err)
}
},
(error) => {
ElMessage.error(this.t('t:base.netError'))
throw error
}
)
}
// ---- 移动端检测 ----
initMobileCheck() {
const check = () => { this.state.isMobile = document.body.clientWidth <= 992 }
window.addEventListener('resize', check)
check()
}
// ---- 全屏切换 ----
fullscreen() {
const doc = document as any
const isFull = !!(doc.webkitIsFullScreen || doc.mozFullScreen || doc.msFullscreenElement || doc.fullscreenElement)
if (isFull) {
doc.exitFullscreen?.() || doc.msExitFullscreen?.() || doc.mozCancelFullScreen?.() || doc.webkitExitFullscreen?.()
} else {
const el = doc.documentElement
el.requestFullscreen?.() || el.msRequestFullscreen?.() || el.mozRequestFullScreen?.() || el.webkitRequestFullscreen?.()
}
}
// ---- 工具方法 ----
clearCache() {
this.localData.clear()
}
reset() {
this.token = null
location.reload()
}
}
export let vueAdmin = new VueAdmin()
export default {
install(app: App) {
app.use(ElementPlus)
app.use(vueAdmin.i18n)
app.use(vueAdmin.router)
for (const key in elIcons) {
app.component(`ElIcon${key}`, (elIcons as any)[key])
// app.component(`el-icon-${key}`, (elIcons as any)[key])
}
},
}
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M258.389333 354.133333a299.093333 299.093333 0 0 1 8.490667-12.8h490.24c2.944 4.181333 5.76 8.490667 8.490667 12.8l86.186666-49.749333 42.666667 73.898667-94.421333 54.528c6.912 25.173333 10.624 51.754667 10.624 79.189333v42.666667h128v85.333333h-128a296.96 296.96 0 0 1-22.869334 114.773333l106.666667 61.610667-42.666667 73.898667-107.776-62.208A298.325333 298.325333 0 0 1 554.666667 935.637333V597.333333h-85.333334v338.346667a298.325333 298.325333 0 0 1-189.354666-107.605333l-107.776 62.208-42.666667-73.898667 106.666667-61.568A297.770667 297.770667 0 0 1 213.333333 640H85.333333v-85.333333h128v-42.666667c0-27.434667 3.712-53.973333 10.624-79.189333L129.536 378.282667l42.666667-73.898667L258.389333 354.133333zM341.333333 256a170.666667 170.666667 0 1 1 341.333334 0H341.333333z" p-id="26147"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M554.666667 849.066667a213.418667 213.418667 0 0 0 170.666666-209.066667v-128a212.48 212.48 0 0 0-17.706666-85.333333h-391.253334A212.48 212.48 0 0 0 298.666667 512v128a213.418667 213.418667 0 0 0 170.666666 209.066667V597.333333h85.333334v251.733334z m-318.464-94.293334A297.770667 297.770667 0 0 1 213.333333 640H85.333333v-85.333333h128v-42.666667c0-27.434667 3.712-53.973333 10.624-79.189333L129.536 378.282667l42.666667-73.898667L258.389333 354.133333a299.093333 299.093333 0 0 1 8.490667-12.8h490.24c2.944 4.181333 5.76 8.490667 8.490667 12.8l86.186666-49.749333 42.666667 73.898667-94.421333 54.528c6.912 25.173333 10.624 51.754667 10.624 79.189333v42.666667h128v85.333333h-128a296.96 296.96 0 0 1-22.869334 114.773333l106.666667 61.610667-42.666667 73.898667-107.776-62.208A298.069333 298.069333 0 0 1 512 938.666667a298.069333 298.069333 0 0 1-232.021333-110.592l-107.776 62.208-42.666667-73.898667 106.666667-61.568zM341.333333 256a170.666667 170.666667 0 1 1 341.333334 0H341.333333z" p-id="25873"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M981.333333 512l-301.696 301.696-60.330666-60.330667L860.672 512l-241.365333-241.365333 60.330666-60.330667L981.333333 512zM163.328 512l241.365333 241.365333-60.330666 60.330667L42.666667 512l301.696-301.696 60.330666 60.330667L163.328 512z" p-id="4503"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M554.666667 426.666667h213.333333l-256 256-256-256h213.333333V128h85.333334v298.666667z m-384 384h682.666666v-298.666667h85.333334v341.333333a42.666667 42.666667 0 0 1-42.666667 42.666667H128a42.666667 42.666667 0 0 1-42.666667-42.666667v-341.333333h85.333334v298.666667z" p-id="26056"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M121.984 122.752l536.32-76.586667a21.333333 21.333333 0 0 1 24.362667 21.12v889.429334a21.333333 21.333333 0 0 1-24.32 21.12L121.941333 901.248a42.666667 42.666667 0 0 1-36.650666-42.24V164.992a42.666667 42.666667 0 0 1 36.650666-42.24zM725.333333 128h170.666667a42.666667 42.666667 0 0 1 42.666667 42.666667v682.666666a42.666667 42.666667 0 0 1-42.666667 42.666667h-170.666667V128z m-290.133333 384L554.666667 341.333333h-102.4L384 438.869333 315.733333 341.333333H213.333333l119.466667 170.666667L213.333333 682.666667h102.4L384 585.130667 452.266667 682.666667H554.666667l-119.466667-170.666667z" p-id="3794"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M725.333333 128h170.666667a42.666667 42.666667 0 0 1 42.666667 42.666667v682.666666a42.666667 42.666667 0 0 1-42.666667 42.666667h-170.666667V128zM121.984 122.752l536.32-76.586667a21.333333 21.333333 0 0 1 24.362667 21.12v889.429334a21.333333 21.333333 0 0 1-24.32 21.12L121.941333 901.248a42.666667 42.666667 0 0 1-36.650666-42.24V164.992a42.666667 42.666667 0 0 1 36.650666-42.24zM213.333333 341.333333v341.333334h85.333334v-85.333334h256V341.333333H213.333333z m85.333334 85.333334h170.666666v85.333333H298.666667v-85.333333z" p-id="3925"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M725.333333 128h170.666667a42.666667 42.666667 0 0 1 42.666667 42.666667v682.666666a42.666667 42.666667 0 0 1-42.666667 42.666667h-170.666667V128zM121.984 122.752l536.32-76.586667a21.333333 21.333333 0 0 1 24.362667 21.12v889.429334a21.333333 21.333333 0 0 1-24.32 21.12L121.941333 901.248a42.666667 42.666667 0 0 1-36.650666-42.24V164.992a42.666667 42.666667 0 0 1 36.650666-42.24zM469.333333 341.333333v212.864L384 469.333333l-84.906667 85.333334L298.666667 341.333333H213.333333v341.333334h85.333334l85.333333-85.333334 85.333333 85.333334h85.333334V341.333333h-85.333334z" p-id="3663"></path></svg>
</template>
@@ -1,21 +0,0 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
width="1em"
height="1em"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 512 512"
>
<path
d="M478.33 433.6l-90-218a22 22 0 0 0-40.67 0l-90 218a22 22 0 1 0 40.67 16.79L316.66 406h102.67l18.33 44.39A22 22 0 0 0 458 464a22 22 0 0 0 20.32-30.4zM334.83 362L368 281.65L401.17 362z"
fill="currentColor"
></path>
<path
d="M267.84 342.92a22 22 0 0 0-4.89-30.7c-.2-.15-15-11.13-36.49-34.73c39.65-53.68 62.11-114.75 71.27-143.49H330a22 22 0 0 0 0-44H214V70a22 22 0 0 0-44 0v20H54a22 22 0 0 0 0 44h197.25c-9.52 26.95-27.05 69.5-53.79 108.36c-31.41-41.68-43.08-68.65-43.17-68.87a22 22 0 0 0-40.58 17c.58 1.38 14.55 34.23 52.86 83.93c.92 1.19 1.83 2.35 2.74 3.51c-39.24 44.35-77.74 71.86-93.85 80.74a22 22 0 1 0 21.07 38.63c2.16-1.18 48.6-26.89 101.63-85.59c22.52 24.08 38 35.44 38.93 36.1a22 22 0 0 0 30.75-4.9z"
fill="currentColor"
></path>
</svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M640 128a42.666667 42.666667 0 0 1 42.666667 42.666667v170.666666a42.666667 42.666667 0 0 1-42.666667 42.666667h-85.333333v85.333333h170.666666a42.666667 42.666667 0 0 1 42.666667 42.666667v128h85.333333a42.666667 42.666667 0 0 1 42.666667 42.666667v170.666666a42.666667 42.666667 0 0 1-42.666667 42.666667h-256a42.666667 42.666667 0 0 1-42.666666-42.666667v-170.666666a42.666667 42.666667 0 0 1 42.666666-42.666667h85.333334v-85.333333H341.333333v85.333333h85.333334a42.666667 42.666667 0 0 1 42.666666 42.666667v170.666666a42.666667 42.666667 0 0 1-42.666666 42.666667H170.666667a42.666667 42.666667 0 0 1-42.666667-42.666667v-170.666666a42.666667 42.666667 0 0 1 42.666667-42.666667h85.333333v-128a42.666667 42.666667 0 0 1 42.666667-42.666667h170.666666V384H384a42.666667 42.666667 0 0 1-42.666667-42.666667V170.666667a42.666667 42.666667 0 0 1 42.666667-42.666667h256zM384 725.333333H213.333333v85.333334h170.666667v-85.333334z m426.666667 0h-170.666667v85.333334h170.666667v-85.333334zM597.333333 213.333333h-170.666666v85.333334h170.666666V213.333333z" p-id="51975"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M170.666667 810.666667h682.666666v-298.666667h85.333334v341.333333a42.666667 42.666667 0 0 1-42.666667 42.666667H128a42.666667 42.666667 0 0 1-42.666667-42.666667v-341.333333h85.333334v298.666667z m384-426.666667v298.666667h-85.333334V384H256l256-256 256 256h-213.333333z" p-id="25917"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M42.666667 128h170.666666l298.666667 512 298.666667-512h170.666666L512 938.666667 42.666667 128z m369.792 0L512 298.666667l99.541333-170.666667h172.16L512 597.333333 240.298667 128h172.16z" p-id="4634"></path></svg>
</template>
@@ -1,3 +0,0 @@
<template>
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M792.490667 585.002667a38.826667 38.826667 0 0 0 38.314666-38.314667c0-21.248-17.024-38.314667-38.314666-38.314667s-38.314667 17.066667-38.314667 38.314667c0 21.333333 17.066667 38.314667 38.314667 38.314667z m-188.8 0a38.826667 38.826667 0 0 0 38.314666-38.314667c0-21.248-17.066667-38.314667-38.314666-38.314667-21.333333 0-38.314667 17.066667-38.314667 38.314667 0 21.333333 17.024 38.314667 38.314667 38.314667z m280.192 215.04a14.805333 14.805333 0 0 0-7.338667 15.786666c0 2.048 0 4.138667 1.066667 6.272 4.181333 17.792 12.544 46.122667 12.544 47.189334 0 3.114667 1.066667 5.205333 1.066666 7.338666a9.386667 9.386667 0 0 1-9.429333 9.386667c-2.133333 0-3.157333-1.024-5.248-2.048l-61.824-35.669333a34.090667 34.090667 0 0 0-14.677333-4.181334c-3.114667 0-6.272 0-8.362667 1.024-29.354667 8.405333-59.733333 12.586667-92.202667 12.586667-156.16 0-281.898667-104.832-281.898666-234.88 0-130.005333 125.738667-234.88 281.898666-234.88 156.117333 0 281.856 104.874667 281.856 234.88 0 70.272-37.717333 134.229333-97.450666 177.237333zM711.381333 345.557333a388.48 388.48 0 0 0-11.946666-0.213333c-178.090667 0-324.522667 122.026667-324.522667 277.546667 0 23.637333 3.413333 46.506667 9.728 68.266666h-3.797333a425.088 425.088 0 0 1-110.250667-15.701333c-3.157333-1.066667-6.314667-1.066667-9.472-1.066667a35.498667 35.498667 0 0 0-17.834667 5.248l-74.581333 42.88c-2.133333 1.066667-4.224 2.133333-6.314667 2.133334a11.648 11.648 0 0 1-11.52-11.52c0-3.157333 1.024-5.248 2.090667-8.405334 1.024-1.024 10.496-35.584 15.744-56.490666 0-2.133333 1.024-5.248 1.024-7.338667a23.722667 23.722667 0 0 0-9.429333-18.858667C87.808 570.709333 42.666667 494.336 42.666667 409.514667 42.666667 253.653333 194.986667 128 381.866667 128c160.64 0 295.68 92.544 329.514666 217.514667z m-219.904 17.834667c24.448 0 43.776-20.352 43.776-43.776 0-24.448-19.328-43.776-43.776-43.776s-43.776 19.328-43.776 43.776 19.328 43.776 43.776 43.776z m-224.426666 0c24.448 0 43.818667-20.352 43.818666-43.776 0-24.448-19.370667-43.776-43.818666-43.776-24.405333 0-43.776 19.328-43.776 43.776s19.370667 43.776 43.776 43.776z" p-id="4372"></path></svg>
</template>
@@ -1,12 +0,0 @@
export { default as Vue } from './Vue.vue'
export { default as Code } from './Code.vue'
export { default as Wechat } from './Wechat.vue'
export { default as BugFill } from './BugFill.vue'
export { default as BugLine } from './BugLine.vue'
export { default as FileWord } from './FileWord.vue'
export { default as FileExcel } from './FileExcel.vue'
export { default as FilePpt } from './FilePpt.vue'
export { default as Organization } from './Organization.vue'
export { default as Upload } from './Upload.vue'
export { default as Download } from './Download.vue'
export { default as Lang } from './Lang.vue'
@@ -1,47 +0,0 @@
<template>
<el-dropdown v-if="vueAdmin.langCount > 1" trigger="click" @command="setLang">
<div class="lang-switch">
<el-icon><Lang /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item in vueAdmin.langList" :key="item.lang" :command="item.lang"
:class="{ 'is-active': item.lang === vueAdmin.locale }">{{
item.name }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script lang="ts" setup>
import { vueAdmin } from "@/VueAdmin";
import Lang from "@/assets/icons/Lang.vue";
function setLang(lang: string) {
vueAdmin.locale = lang
}
</script>
<style scoped>
.lang-switch {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 4px;
color: #999;
}
.lang-switch .el-icon {
font-size: 16px;
}
</style>
<style>
.el-dropdown-menu .is-active {
color: var(--el-color-primary) !important;
font-weight: bold;
}
</style>
@@ -1,122 +0,0 @@
<template>
<div class="power-tree-wrap">
<el-tree ref="treeRef" :data="treeData" show-checkbox node-key="key" default-expand-all
:props="{ label: 'label', children: 'children' }" @check="onCheck" />
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick } from 'vue'
import { vueAdmin } from '@/VueAdmin'
import type { IMenuItem, ILabel } from '@shrimp/IShrimpType'
interface IPowerItem {
key: string
label: ILabel
}
const props = defineProps<{
modelValue: string[]
allPowers: IPowerItem[]
menuTree: IMenuItem[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
const treeRef = ref<any>(null)
interface PowerNode { key: string; label: string; children?: PowerNode[] }
const MENU_PREFIX = '_menu:'
const treeData = computed(() => {
function walkMenu(items: IMenuItem[]): PowerNode[] {
return items.reduce<PowerNode[]>((acc, item) => {
if (item.moduleKey) {
const modulePowers = props.allPowers.filter(p => p.key === item.moduleKey)
const children = props.allPowers
.filter(p => p.key.startsWith(item.moduleKey + '.'))
.map(p => ({ key: p.key, label: vueAdmin.t(p.label) }))
// 模块本身(view 权限)
if (modulePowers.length > 0) {
children.unshift({
key: item.moduleKey,
label: vueAdmin.t({ t: "power.view" }),
})
}
const node: PowerNode = {
key: MENU_PREFIX + item.moduleKey,
label: vueAdmin.t(item.label),
children: children.length ? children : undefined,
}
acc.push(node)
} else if (item.children?.length) {
const children = walkMenu(item.children)
if (children.length) {
acc.push({
key: MENU_PREFIX + (item.name || ''),
label: vueAdmin.t(item.label),
children,
})
}
}
return acc
}, [])
}
return walkMenu(props.menuTree)
})
function onCheck(data: any, state: any) {
const allKeys: string[] = state.checkedKeys
let keys = allKeys.filter((k: string) => !k.startsWith(MENU_PREFIX))
const toggledKey: string = data.key
// 菜单分组节点的子节点已由 el-tree 自动处理,直接返回
if (toggledKey.startsWith(MENU_PREFIX)) {
emit('update:modelValue', keys)
return
}
const checked = new Set(keys)
const isChecked = checked.has(toggledKey)
if (isChecked) {
// 勾选子权限时,自动勾选所有祖先
let ancestor = toggledKey
while (ancestor.includes('.')) {
ancestor = ancestor.substring(0, ancestor.lastIndexOf('.'))
checked.add(ancestor)
}
} else {
// 取消父权限时,自动取消所有后代
const prefix = toggledKey + '.'
keys = keys.filter(key => !key.startsWith(prefix))
emit('update:modelValue', keys)
return
}
emit('update:modelValue', [...checked])
}
watch(() => [props.modelValue, props.allPowers], () => {
nextTick(() => treeRef.value?.setCheckedKeys(props.modelValue || []))
}, { immediate: true })
</script>
<style scoped>
.power-tree-wrap {
border: 1px solid var(--el-border-color);
border-radius: 4px;
background: var(--el-bg-color);
padding: 8px 12px;
width: 100%;
}
.power-tree-wrap :deep(.el-tree) {
background: transparent;
}
</style>
@@ -1,126 +0,0 @@
<template>
<el-drawer class="shrimp-drawer" :model-value="visible" @update:model-value="$emit('update:visible', $event)" :title="title" size="400px">
<el-form ref="formRef" :model="formData" :rules="formRules" @submit.prevent>
<template v-for="field in fields" :key="field.key">
<el-form-item :label="vueAdmin.t(field.label)" :prop="field.key" :required="field.required">
<slot :name="`form-${field.key}`" :field="field" :data="formData" :isEdit="isEdit">
<component :is="vueAdmin.getFieldComponent(moduleKey, field.type, field.key)" :field="field"
:model-value="formData[field.key]"
@update:model-value="onFieldChange(field.key, $event)"
mode="edit"
:disabled="isEdit && (field.editable === 'readonly' || field.editable === 'addOnly')" />
</slot>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="$emit('update:visible', false)">{{ $t('base.cancel') }}</el-button>
<el-button type="primary" :disabled="!canSave" @click="$emit('save')">{{ $t('base.confirm') }}</el-button>
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { provide, computed, ref } from 'vue'
import { IFieldConfig, IFieldRule } from '@shrimp/IShrimpType'
import { vueAdmin } from '@/VueAdmin'
import type { FormInstance, FormItemRule } from 'element-plus'
interface Props {
visible: boolean
title: string
moduleKey: string
fields: IFieldConfig[]
formData: Record<string, any>
isEdit: boolean
canSave?: boolean
}
const props = defineProps<Props>()
provide('shrimpModuleKey', props.moduleKey)
const emit = defineEmits<{
'update:visible': [value: boolean]
'update:formData': [value: Record<string, any>]
save: []
}>()
const formRef = ref<FormInstance>()
function buildRules(field: IFieldConfig): FormItemRule[] {
const rules: FormItemRule[] = []
if (field.required) {
rules.push({
required: true,
message: vueAdmin.t('shrimp.fieldRequired'),
trigger: 'change',
...(field.type === 'string' || field.type === 'textarea' || field.type === 'password' ? { trigger: 'blur' } : {}),
})
}
if (field.rules) {
for (const r of field.rules) {
const rule: FormItemRule = { trigger: 'change' }
if (r.min !== undefined) rule.min = r.min
if (r.max !== undefined) rule.max = r.max
if (r.pattern) rule.pattern = new RegExp(r.pattern)
if (r.message) rule.message = vueAdmin.t(r.message)
rules.push(rule)
}
}
return rules
}
const formRules = computed(() => {
const rules: Record<string, FormItemRule[]> = {}
for (const field of props.fields) {
const list = buildRules(field)
if (list.length > 0) {
rules[field.key] = list
}
}
return rules
})
function onFieldChange(key: string, value: any) {
emit('update:formData', { ...props.formData, [key]: value })
}
async function validate(): Promise<boolean> {
if (!formRef.value) return true
try {
await formRef.value.validate()
return true
} catch {
return false
}
}
defineExpose({ validate })
</script>
<style>
.shrimp-drawer .el-drawer__header {
margin-bottom: 0;
padding: 16px 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.shrimp-drawer .el-drawer__body {
padding: 20px;
}
.shrimp-drawer .el-drawer__footer {
padding: 12px 20px;
border-top: 1px solid var(--el-border-color-lighter);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.shrimp-drawer .el-drawer__body .el-form-item {
margin-bottom: 20px;
}
</style>
@@ -1,104 +0,0 @@
<template>
<el-drawer class="shrimp-drawer" :model-value="visible" @update:model-value="$emit('update:visible', $event)"
:title="title" size="400px">
<el-form :model="localFilterData">
<template v-for="field in filterableFields" :key="field.key">
<el-form-item :label="vueAdmin.t(field.label)">
<slot :name="`filter-${field.key}`" :field="field" :data="localFilterData">
<component :is="vueAdmin.getFieldComponent(moduleKey, field.type, field.key)" :field="field"
:model-value="localFilterData[field.key]"
@update:model-value="onFieldChange(field.key, $event)" mode="filter" />
</slot>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="handleReset">{{ $t('base.reset') }}</el-button>
<el-button type="primary" @click="handleConfirm">{{ $t('base.confirm') }}</el-button>
</template>
</el-drawer>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { IFieldConfig } from '@shrimp/IShrimpType'
import { vueAdmin } from '@/VueAdmin'
interface Props {
visible: boolean
title: string
moduleKey: string
filterableFields: IFieldConfig[]
filterData: Record<string, any>
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:visible': [value: boolean]
confirm: [value: Record<string, any>]
reset: []
}>()
const localFilterData = ref<Record<string, any>>({})
const closedByReset = ref(false)
function hasChanged(): boolean {
return JSON.stringify(localFilterData.value) !== JSON.stringify(props.filterData)
}
watch(() => props.visible, (val, oldVal) => {
if (oldVal === false && val === true) {
closedByReset.value = false
localFilterData.value = { ...props.filterData }
}
if (oldVal === true && val === false) {
if (closedByReset.value) {
closedByReset.value = false
emit('reset')
return
}
if (hasChanged()) {
emit('confirm', { ...localFilterData.value })
}
}
})
function onFieldChange(key: string, value: any) {
localFilterData.value = { ...localFilterData.value, [key]: value }
}
function handleReset() {
closedByReset.value = true
localFilterData.value = {}
emit('update:visible', false)
}
function handleConfirm() {
emit('update:visible', false)
}
</script>
<style>
.shrimp-drawer .el-drawer__header {
margin-bottom: 0;
padding: 16px 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.shrimp-drawer .el-drawer__body {
padding: 20px;
}
.shrimp-drawer .el-drawer__footer {
padding: 12px 20px;
border-top: 1px solid var(--el-border-color-lighter);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.shrimp-drawer .el-drawer__body .el-form-item {
margin-bottom: 20px;
}
</style>
@@ -1,22 +0,0 @@
<template>
<el-icon :size="18" style="cursor:pointer"
:style="{ color: active ? 'var(--el-color-primary)' : '' }" @click="$emit('click')">
<Filter />
</el-icon>
</template>
<script lang="ts" setup>
import { Filter } from '@element-plus/icons-vue'
interface Props {
active?: boolean
}
withDefaults(defineProps<Props>(), {
active: false
})
defineEmits<{
click: []
}>()
</script>
@@ -1,88 +0,0 @@
<template>
<div class="header-search" :class="{ show: visible }">
<el-icon :size="18" @click.stop="toggleSearch"
:style="{ color: active ? 'var(--el-color-primary)' : '' }"
><el-icon-search /></el-icon>
<el-input ref="searchInput" :model-value="keyword" class="header-search-input" clearable
@update:model-value="$emit('update:keyword', $event)"
@keyup.enter="onSearch" @blur="onSearchBlur" @clear="onSearch" />
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
interface Props {
keyword: string
visible: boolean
active?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:keyword': [value: string]
'update:visible': [value: boolean]
search: []
}>()
const searchInput = ref()
function toggleSearch() {
const next = !props.visible
emit('update:visible', next)
if (next) {
setTimeout(() => searchInput.value?.focus(), 200)
}
}
function onSearchBlur() {
if (!props.keyword) {
emit('update:visible', false)
}
}
function onSearch() {
emit('search')
}
</script>
<style scoped>
.header-search {
font-size: 0 !important;
display: flex;
align-items: center;
}
.header-search .el-icon {
cursor: pointer;
font-size: 18px;
vertical-align: middle;
}
.header-search-input {
font-size: 14px;
transition: width 0.2s;
width: 0;
overflow: hidden;
background: transparent;
border-radius: 0;
display: inline-block;
vertical-align: middle;
}
.header-search-input :deep(.el-input__inner) {
border-radius: 0;
border: 0;
padding-left: 0;
padding-right: 0;
box-shadow: none !important;
border-bottom: 1px solid var(--el-border-color);
vertical-align: middle;
}
.header-search.show .header-search-input {
width: 200px;
margin-left: 10px;
}
</style>
@@ -1,583 +0,0 @@
<template>
<el-container class="shrimp-table" direction="vertical">
<!-- 顶部区域:左侧操作按钮 + 右侧搜索 -->
<div class="aside" v-if="hasActions || searchableFields.length > 0 || filterableFields.length > 0">
<div class="aside-left">
<el-button v-if="can('add')" type="primary" :icon="Plus" circle size="large" @click="handleAdd" />
<el-button v-if="can('clear')" :icon="DeleteIcon" circle size="large" @click="handleClear" />
<el-button v-if="can('export')" :icon="Download" circle size="large" @click="handleExport" />
<el-button v-if="can('import')" :icon="Upload" circle size="large" @click="handleImportClick" />
<input ref="fileInput" type="file" accept=".xlsx,.xls,.json" style="display: none" @change="handleFileChange" />
<slot name="actions"></slot>
</div>
<div class="aside-right">
<ShrimpSearchInput v-if="searchableFields.length > 0" v-model:keyword="keyword"
v-model:visible="searchVisible" :active="!!searchKeyword" @search="onSearch" />
<ShrimpFilterTrigger v-if="filterableFields.length > 0" :active="hasFilter"
@click="filterDrawerVisible = true" />
</div>
</div>
<!-- 表格主体(可滚动) -->
<el-main>
<el-table :data="data" :loading="loading" stripe border style="width: 100%" v-bind="$attrs"
@sort-change="onSortChange">
<template v-for="field in visibleFields" :key="field.key">
<el-table-column :prop="field.key" :label="vueAdmin.t(field.label)"
:sortable="field.sortable ? 'custom' : false">
<template #default="{ row }">
<slot :name="`column-${field.key}`" :row="row" :field="field">
<component :is="vueAdmin.getFieldComponent(moduleKey, field.type, field.key)" :field="field"
:model-value="row[field.key]" mode="display"
v-bind="field.type === 'relation' || field.type === 'textarea' ? { displayLabel: row[field.key + '_label'] } : {}" />
</slot>
</template>
</el-table-column>
</template>
<el-table-column :label="$t('base.operation')" :width="rowActionsWidth" v-if="hasRowActions">
<template #default="{ row }">
<el-button v-if="hasActiveField && can('edit')" link :type="row.active ? 'warning' : 'success'" :icon="row.active ? VideoPause : VideoPlay" size="large" @click="handleToggleActive(row)" />
<el-button v-if="can('edit')" link type="primary" :icon="Edit" size="large" @click="handleEdit(row)" />
<el-button v-if="can('delete')" link type="danger" :icon="DeleteIcon" size="large" @click="handleDelete(row)" />
<el-tooltip v-for="sub in subModules" :key="sub.moduleKey" :content="vueAdmin.t(sub.label)">
<el-button link type="primary" :icon="resolveIcon(sub.icon)" size="large"
@click="handleSubJump(sub, row)" />
</el-tooltip>
<slot name="row-actions" :row="row"></slot>
</template>
</el-table-column>
</el-table>
</el-main>
<!-- 底部区域:分页 + 扩展(固定在容器底部) -->
<el-footer height="auto" v-if="total > 0 || $slots.footer">
<el-pagination v-if="total > 0" v-model:current-page="currentPage" v-model:page-size="currentPageSize"
:total="total" @current-change="refresh" />
<slot name="footer"></slot>
</el-footer>
<!-- 新增/编辑抽屉 -->
<ShrimpEditDrawer ref="drawerRef" v-model:visible="dialogVisible" :title="isEdit ? $t('base.edit') : $t('base.add')"
:moduleKey="props.moduleKey" :fields="formFields" v-model:formData="formData" :isEdit="isEdit"
:canSave="canSave" @save="handleSave">
<template v-for="name in formSlotNames" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps" />
</template>
</ShrimpEditDrawer>
<!-- 筛选抽屉 -->
<ShrimpFilterDrawer v-model:visible="filterDrawerVisible" :title="$t('base.filter')"
:moduleKey="props.moduleKey" :filterableFields="filterableFields" :filterData="filterData"
@confirm="handleFilterConfirm" @reset="handleFilterReset">
<template v-for="name in filterSlotNames" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps" />
</template>
</ShrimpFilterDrawer>
</el-container>
</template>
<script lang="ts" setup>
import { ref, computed, useSlots, onMounted, type Component } from 'vue'
import { ITableCrudProto, IFieldConfig, IModuleInfo, IPageQuery } from '@shrimp/IShrimpType'
import { vueAdmin } from '@/VueAdmin'
import { Plus, Delete as DeleteIcon, Download, Upload, Edit, VideoPlay, VideoPause, Operation } from '@element-plus/icons-vue'
import * as ElIcons from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useTableUrlSync } from '@/composables/useTableUrlSync'
import ShrimpEditDrawer from './ShrimpEditDrawer.vue'
import ShrimpFilterDrawer from './ShrimpFilterDrawer.vue'
import ShrimpSearchInput from './ShrimpSearchInput.vue'
import ShrimpFilterTrigger from './ShrimpFilterTrigger.vue'
import * as XLSX from 'xlsx'
const { t } = useI18n()
interface Props {
moduleKey: string
pageSize?: number
/** 显式锁定的筛选条件(覆盖自动 ownerKey 推断);用户无法修改,会合并进 filter 并在筛选抽屉中隐藏 */
baseFilter?: Record<string, any>
}
const props = withDefaults(defineProps<Props>(), {
pageSize: 20,
baseFilter: () => ({})
})
const emit = defineEmits<{
export: []
import: []
}>()
const api = vueAdmin.getModule<ITableCrudProto<any>>(props.moduleKey)
const slots = useSlots()
// ---- URL 同步 ----
const {
currentPage,
currentPageSize,
keyword,
searchKeyword,
currentSortBy,
currentSortOrder,
filterData,
restore,
} = useTableUrlSync({ pageSize: props.pageSize })
// ---- 搜索 ----
const searchVisible = ref(false)
function onSearch() {
searchKeyword.value = keyword.value
currentPage.value = 1
refresh()
}
// ---- 筛选 ----
const filterDrawerVisible = ref(false)
const hasFilter = computed(() => {
return Object.values(filterData.value).some(val => {
if (val === '' || val === null || val === undefined) return false
if (Array.isArray(val) && val.length === 0) return false
return true
})
})
async function handleFilterConfirm(newFilterData: Record<string, any>) {
filterData.value = newFilterData
currentPage.value = 1
await refresh()
}
async function handleFilterReset() {
filterData.value = {}
currentPage.value = 1
await refresh()
}
// ---- 远程排序 ----
function onSortChange({ prop, order }: { prop: string; order: string | null }) {
if (order) {
currentSortBy.value = prop
currentSortOrder.value = order === 'ascending' ? 'asc' : 'desc'
} else {
currentSortBy.value = ''
}
refresh()
}
// ---- 字段配置 ----
const moduleInfo = computed<IModuleInfo | undefined>(() =>
vueAdmin.moduleInfo.find(m => m.moduleKey === props.moduleKey)
)
const fields = computed<IFieldConfig[]>(() => moduleInfo.value?.fields || [])
// ---- 子表 ownerId 推断 ----
// 子模块约定: moduleInfo.parent 存在 → 从路由 ?id= 取父记录 id,作为 ownerId 顶层参数发给后端
// 后端按自己的 ownerKey 配置翻译为 filter 注入(前端不构造 {ownerKey: id} 这种业务字段名 payload)
const route = useRoute()
const router = useRouter()
const ownerId = computed<string | undefined>(() => {
if (!moduleInfo.value?.parent) return undefined
return (route.query.id as string | undefined) || undefined
})
const visibleFields = computed(() => fields.value.filter(f => !f.hidden && !f.tableHide))
const formFields = computed(() => {
const list = fields.value.filter(f => !f.hidden)
if (isEdit.value) {
return list.filter(f => f.editable !== 'readonly')
}
return list.filter(f => f.editable !== 'readonly')
})
const searchableFields = computed(() => fields.value.filter(f => f.searchable))
const filterableFields = computed(() => {
const lockedKeys = new Set(Object.keys(props.baseFilter || {}))
return fields.value.filter(f => f.filterable && !lockedKeys.has(f.key))
})
const formSlotNames = computed(() => Object.keys(slots).filter(name => name.startsWith('form-')))
const filterSlotNames = computed(() => Object.keys(slots).filter(name => name.startsWith('filter-')))
// ---- 表格状态 ----
const data = ref<any[]>([])
const loading = ref(false)
const total = ref(0)
// ---- 对话框状态 ----
const dialogVisible = ref(false)
const isEdit = ref(false)
const currentId = ref('')
const formData = ref<Record<string, any>>({})
const originalData = ref<Record<string, any>>({})
const drawerRef = ref<any>(null)
// ---- 权限 ----
const can = vueAdmin.can(props.moduleKey)
const hasActions = computed(() => can('add') || can('clear') || can('export') || can('import') || !!slots.actions)
// ---- 是否有 active 字段 ----
const hasActiveField = computed(() => {
return fields.value.some(f => f.key === 'active')
})
// ---- 子模块跳转 ----
const subModules = computed<IModuleInfo[]>(() =>
vueAdmin.moduleInfo.filter(m => m.parent === props.moduleKey)
)
function resolveIcon(name?: string): Component {
if (name && (ElIcons as any)[name]) return (ElIcons as any)[name]
return Operation
}
function handleSubJump(sub: IModuleInfo, row: any) {
// 跳子模块: 把当前 ?id 追加进 pids 链, row._id 作为新的 ?id
// 这样多层嵌套下面包屑/返回回到上层时能恢复对应父记录 id
const currentPids = ((route.query.pids as string) || '').split(',').filter(Boolean)
const currentId = route.query.id as string | undefined
const newPids = currentId ? [...currentPids, currentId] : currentPids
const query: Record<string, string> = { id: row._id }
if (newPids.length > 0) query.pids = newPids.join(',')
router.push({ path: `${route.path}/${sub.moduleKey}`, query })
}
const hasRowActions = computed(() => can('edit') || can('delete') || hasActiveField.value || subModules.value.length > 0 || !!slots['row-actions'])
const rowActionsWidth = computed(() => {
let n = 0
if (hasActiveField.value && can('edit')) n++
if (can('edit')) n++
if (can('delete')) n++
n += subModules.value.length
return Math.max(120, 40 + n * 40)
})
// ---- 编辑时是否有改动 ----
const canSave = computed(() => {
if (!isEdit.value) return true
return formFields.value.some(field => {
if (field.editable === 'readonly') return false
if (field.editable === 'addOnly') return false
return formData.value[field.key] !== originalData.value[field.key]
})
})
// ---- CRUD ----
async function handleAdd() {
isEdit.value = false
currentId.value = ''
formData.value = {}
const baseKeys = new Set(Object.keys(props.baseFilter || {}))
formFields.value.forEach(field => {
if (baseKeys.has(field.key)) {
formData.value[field.key] = props.baseFilter![field.key]
} else if (field.defaultValue !== undefined) {
formData.value[field.key] = field.defaultValue
} else if (field.type === 'boolean') {
formData.value[field.key] = false
} else if (field.type === 'number') {
formData.value[field.key] = 0
} else if (field.multiple) {
formData.value[field.key] = []
} else {
formData.value[field.key] = ''
}
})
dialogVisible.value = true
}
async function handleEdit(row: any) {
isEdit.value = true
currentId.value = row._id
originalData.value = { ...row }
formData.value = { ...row }
dialogVisible.value = true
}
async function handleSave() {
const valid = await drawerRef.value?.validate()
if (!valid) return
try {
const submitData: Record<string, any> = {}
if (isEdit.value) {
// 编辑:只提交修改过的字段
formFields.value.forEach(field => {
if (field.editable === 'readonly') return
if (field.editable === 'addOnly') return
const newValue = formData.value[field.key]
const oldValue = originalData.value[field.key]
if (newValue === oldValue) return
submitData[field.key] = newValue
})
} else {
// 新增:提交所有非 readonly 字段
formFields.value.forEach(field => {
if (field.editable === 'readonly') return
submitData[field.key] = formData.value[field.key]
})
}
if (isEdit.value) {
if (Object.keys(submitData).length === 0) {
dialogVisible.value = false
return
}
await api.edit(currentId.value, submitData)
} else {
await api.add(submitData, ownerId.value)
}
dialogVisible.value = false
ElMessage.success(isEdit.value ? t('table.editSuccess') : t('table.addSuccess'))
await refresh()
} catch (e: any) {
console.warn(`[ShrimpTable]`, e.message)
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(t('table.confirmDelete'), t('base.confirm'), {
confirmButtonText: t('base.confirm'),
cancelButtonText: t('base.cancel'),
type: 'warning',
})
await api.delete(row._id)
await refresh()
} catch {
// 用户取消
}
}
async function handleToggleActive(row: any) {
try {
await api.edit(row._id, { active: !row.active })
await refresh()
ElMessage.success(t('base.success'))
} catch (e: any) {
console.warn(`[ShrimpTable]`, e.message)
}
}
async function handleClear() {
try {
await ElMessageBox.confirm(t('table.confirmClear'), t('base.delete'), {
confirmButtonText: t('base.confirm'),
cancelButtonText: t('base.cancel'),
type: 'warning',
})
const query: Record<string, any> = {}
if (searchKeyword.value) query.keyword = searchKeyword.value
const filter = buildFilter()
if (filter) query.filter = filter
if (ownerId.value) query.ownerId = ownerId.value
await api.clear(query)
await refresh()
} catch {
// 用户取消
}
}
// ---- 导出 ----
function buildFilter(): Record<string, any> | undefined {
const filter: Record<string, any> = {}
for (const [key, val] of Object.entries(filterData.value)) {
if (val === '' || val === null || val === undefined) continue
if (Array.isArray(val) && val.length === 0) continue
if (typeof val === 'object' && !Array.isArray(val)) {
const range: Record<string, any> = {}
if (val.min !== undefined && val.min !== null) range.min = val.min
if (val.max !== undefined && val.max !== null) range.max = val.max
if (Object.keys(range).length > 0) filter[key] = range
} else {
filter[key] = val
}
}
// baseFilter 优先级最高,覆盖用户选择
Object.assign(filter, props.baseFilter || {})
return Object.keys(filter).length > 0 ? filter : undefined
}
async function handleExport() {
emit('export')
try {
const query: Record<string, any> = {}
if (searchKeyword.value) query.keyword = searchKeyword.value
const filter = buildFilter()
if (filter) query.filter = filter
if (currentSortBy.value) {
query.sortBy = currentSortBy.value
query.sortOrder = currentSortOrder.value
}
if (ownerId.value) query.ownerId = ownerId.value
const list = await api.export(query)
const ws = XLSX.utils.json_to_sheet(list)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, props.moduleKey)
XLSX.writeFile(wb, `${props.moduleKey}_export_${Date.now()}.xlsx`)
ElMessage.success(t('base.success'))
} catch (e: any) {
console.warn(`[ShrimpTable] export failed`, e.message)
}
}
// ---- 导入 ----
const fileInput = ref<HTMLInputElement | null>(null)
function handleImportClick() {
emit('import')
fileInput.value?.click()
}
async function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
try {
let data: any[]
if (file.name.endsWith('.json')) {
const text = await file.text()
data = JSON.parse(text)
} else {
const buf = await file.arrayBuffer()
const wb = XLSX.read(buf, { type: 'array' })
const ws = wb.Sheets[wb.SheetNames[0]]
data = XLSX.utils.sheet_to_json(ws)
}
await api.import(data, ownerId.value)
ElMessage.success(t('base.success'))
await refresh()
} catch (e: any) {
// if (!e.handled) {
// ElMessage.error(t('base.fail'))
// }
// console.warn(`[ShrimpTable] import failed`, e.message)
} finally {
target.value = ''
}
}
// ---- 数据加载 ----
async function refresh(query?: Record<string, any>) {
loading.value = true
try {
const pageQuery: IPageQuery & Record<string, any> = {
page: currentPage.value,
pageSize: currentPageSize.value,
}
if (searchKeyword.value) {
pageQuery.keyword = searchKeyword.value
}
if (currentSortBy.value) {
pageQuery.sortBy = currentSortBy.value
pageQuery.sortOrder = currentSortOrder.value
}
// 筛选条件:仅传有值的字段,放到 filter 对象中
const filter = buildFilter()
if (filter) {
pageQuery.filter = filter
}
// 子表场景: 顶层 ownerId 由后端按 ownerKey 配置翻译
if (ownerId.value) {
pageQuery.ownerId = ownerId.value
}
Object.assign(pageQuery, query)
const res = await api.list(pageQuery)
data.value = res.list || []
total.value = res.total
currentPage.value = res.page
currentPageSize.value = res.pageSize
} finally {
loading.value = false
}
}
// ---- 暴露 ----
defineExpose({
data,
loading,
refresh,
can,
api,
keyword,
})
onMounted(() => {
restore()
if (keyword.value) {
searchVisible.value = true
}
refresh()
})
</script>
<style scoped>
.shrimp-table {
width: 100%;
height: 100%;
}
/* 顶部区域:左右分布 */
.aside {
padding: 4px 10px;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.aside-left {
display: flex;
gap: 10px;
align-items: center;
}
.aside-right {
display: flex;
gap: 10px;
align-items: center;
}
/* 表格主体:内部纵向滚动(分页固定在 footer,不参与滚动) */
.shrimp-table :deep(.el-main) {
padding: 0;
overflow: auto;
flex: 1;
min-height: 0;
}
/* 底部分页:固定在容器底,不随内容滚动 */
.shrimp-table :deep(.el-footer) {
padding: 12px 0;
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
/* 强制放大操作栏图标 */
.shrimp-table :deep(.el-button .el-icon) {
font-size: 20px;
width: 20px;
height: 20px;
}
</style>
@@ -1,36 +0,0 @@
<template>
<el-tag v-if="mode === 'display'" :type="modelValue ? 'success' : 'danger'" size="small">
{{ modelValue ? $t('table.yes') : $t('table.no') }}
</el-tag>
<el-select
v-else-if="mode === 'filter'"
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
clearable
:placeholder="$t('table.unlimited')"
style="width: 100%"
>
<el-option :label="$t('table.yes')" :value="true" />
<el-option :label="$t('table.no')" :value="false" />
</el-select>
<el-switch v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" />
</template>
<script lang="ts" setup>
import { watch } from 'vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
watch(() => props.field.multiple, (v) => {
if (v) console.warn('[FieldBoolean] multiple is not supported, fallback to single value')
}, { immediate: true })
</script>
@@ -1,137 +0,0 @@
<template>
<template v-if="mode === 'display'">
<template v-if="Array.isArray(modelValue)">
<span v-if="!modelValue.length">-</span>
<span v-else class="dt-group">
<template v-for="(p, i) in parsedArray" :key="i">
<el-tooltip v-if="p" :content="p.full" placement="top" :show-after="800">
<span class="dt">
<el-icon class="ico"><Calendar /></el-icon>
<span class="d">{{ p.day }}</span>
<span class="t">{{ p.time }}</span>
</span>
</el-tooltip>
<span v-else>-</span>
</template>
</span>
</template>
<el-tooltip v-else-if="parsed" :content="parsed.full" placement="top" :show-after="800">
<span class="dt">
<el-icon class="ico"><Calendar /></el-icon>
<span class="d">{{ parsed.day }}</span>
<span class="t">{{ parsed.time }}</span>
</span>
</el-tooltip>
<span v-else>-</span>
</template>
<el-date-picker
v-else-if="mode === 'filter'"
:model-value="filterModelValue"
@update:model-value="onFilterChange($event)"
type="datetimerange"
:start-placeholder="$t('table.startTime')"
:end-placeholder="$t('table.endTime')"
style="width: 100%"
/>
<el-date-picker
v-else-if="field.multiple"
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:disabled="disabled"
type="dates"
style="width: 100%"
/>
<el-date-picker v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled"
type="datetime" style="width: 100%" />
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { Calendar } from '@element-plus/icons-vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
function pad(n: number) { return n < 10 ? '0' + n : String(n) }
function parse(v: any): { day: string; time: string; full: string } | null {
if (v == null || v === '') return null
const d = new Date(v)
if (isNaN(d.getTime())) return null
const y = d.getFullYear()
const mo = pad(d.getMonth() + 1)
const da = pad(d.getDate())
const h = pad(d.getHours())
const mi = pad(d.getMinutes())
const se = pad(d.getSeconds())
const day = da
const time = `${h}:${mi}`
const full = `${y}-${mo}-${da} ${h}:${mi}:${se}`
return { day, time, full }
}
const parsed = computed(() => parse(props.modelValue))
const parsedArray = computed(() => Array.isArray(props.modelValue) ? props.modelValue.map(parse) : [])
const filterModelValue = computed(() => {
if (!props.modelValue || typeof props.modelValue !== 'object') return null
const { min, max } = props.modelValue
if (min != null && max != null) {
return [new Date(min), new Date(max)]
}
return null
})
function onFilterChange(dates: Date[] | null) {
if (dates && dates.length === 2) {
emit('update:modelValue', { min: dates[0].getTime(), max: dates[1].getTime() })
} else {
emit('update:modelValue', null)
}
}
</script>
<style scoped>
.dt-group {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
}
.dt {
display: inline-flex;
align-items: flex-end;
width: 100px;
height: 24px;
box-sizing: border-box;
padding: 0 10px 3px;
background: var(--el-fill-color-blank);
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
font-variant-numeric: tabular-nums;
white-space: nowrap;
font-size: 13px;
line-height: 1;
color: var(--el-text-color-regular);
vertical-align: middle;
}
.ico {
color: var(--el-text-color-placeholder);
font-size: 16px;
margin-right: 4px;
}
.d {
color: var(--el-text-color-regular);
}
.t {
margin-left: auto;
color: var(--el-text-color-regular);
}
</style>
@@ -1,48 +0,0 @@
<template>
<template v-if="mode === 'display'">
<el-tooltip v-if="modelValue != null" :content="displayValue" placement="top">
<span class="json-display">{{ displayValue }}</span>
</el-tooltip>
<span v-else>-</span>
</template>
<el-input
v-else
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
type="textarea"
:rows="4"
:disabled="disabled"
placeholder="请输入 JSON"
/>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
const displayValue = computed(() => {
if (props.modelValue == null) return ''
if (typeof props.modelValue === 'string') return props.modelValue
return JSON.stringify(props.modelValue, null, 2)
})
</script>
<style scoped>
.json-display {
display: inline-block;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -1,82 +0,0 @@
<template>
<template v-if="mode === 'display'">
<template v-if="Array.isArray(modelValue)">{{ modelValue.join(', ') || 0 }}</template>
<template v-else>{{ modelValue ?? 0 }}</template>
</template>
<div v-else-if="mode === 'filter'" style="display: flex; gap: 8px; align-items: center; width: 100%">
<el-input-number
:model-value="modelValue?.min"
@update:model-value="emitRange('min', $event)"
:placeholder="$t('table.min')"
controls-position="right"
style="flex: 1"
/>
<span style="color: #999">~</span>
<el-input-number
:model-value="modelValue?.max"
@update:model-value="emitRange('max', $event)"
:placeholder="$t('table.max')"
controls-position="right"
style="flex: 1"
/>
</div>
<div v-else-if="field.multiple" class="tags-input">
<el-tag v-for="(num, index) in (modelValue || [])" :key="num + '-' + index" closable @close="removeNum(index)">
{{ num }}
</el-tag>
<el-input-number
v-model="inputValue"
@keyup.enter="addNum"
@blur="addNum"
:placeholder="$t('table.pressEnterForNumber')"
:controls="false"
style="width: 120px; flex: 1; min-width: 120px"
/>
</div>
<el-input-number v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" style="width: 100%" />
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const inputValue = ref<number | undefined>(undefined)
function emitRange(key: 'min' | 'max', val: number | undefined) {
const current = props.modelValue || {}
emit('update:modelValue', { ...current, [key]: val })
}
function addNum() {
if (inputValue.value == null) return
const current = Array.isArray(props.modelValue) ? props.modelValue : []
emit('update:modelValue', [...current, inputValue.value])
inputValue.value = undefined
}
function removeNum(index: number) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current.splice(index, 1)
emit('update:modelValue', current)
}
</script>
<style scoped>
.tags-input {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
width: 100%;
}
</style>
@@ -1,20 +0,0 @@
<template>
<template v-if="mode === 'display'">******</template>
<template v-else-if="field.multiple">
<el-alert type="warning" :title="$t('table.passwordMultipleNotSupported')" :closable="false" />
</template>
<el-input v-else type="password" show-password :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" />
</template>
<script lang="ts" setup>
defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit'
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
</script>
@@ -1,94 +0,0 @@
<template>
<template v-if="mode === 'display'">
<template v-if="field.multiple && Array.isArray(displayLabel)">{{ displayLabel.filter(Boolean).join(', ') || '-' }}</template>
<template v-else>{{ displayLabel || modelValue || '-' }}</template>
</template>
<el-select
v-else-if="mode === 'filter'"
:model-value="stringValue"
@update:model-value="$emit('update:modelValue', $event)"
multiple clearable filterable remote :remote-method="onSearch" :loading="loading"
style="width: 100%"
>
<el-option v-for="opt in options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-select v-else :model-value="stringValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" :multiple="field.multiple" filterable remote :remote-method="onSearch" :loading="loading" style="width: 100%">
<el-option v-for="opt in options" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
</template>
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue'
import { vueAdmin } from '@/VueAdmin'
import { ITableCrudProto } from '@shrimp/IShrimpType'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
displayLabel?: string | string[]
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
const options = ref<{ label: string; value: string }[]>([])
const loading = ref(false)
/** 统一转为字符串值,解决 ObjectId 与 el-select 的匹配问题 */
const stringValue = computed(() => {
// filter 模式强制 multiple,始终返回数组
if (props.mode === 'filter' || props.field.multiple) {
return Array.isArray(props.modelValue) ? props.modelValue.map(String) : []
}
return props.modelValue != null ? String(props.modelValue) : undefined
})
async function onSearch(query: string) {
if (!props.field.relation) return
loading.value = true
try {
const api = vueAdmin.getModule<ITableCrudProto<any>>(props.field.relation.moduleKey)
const listQuery: any = { keyword: query, pageSize: 20 }
if (props.field.filter) {
listQuery.filter = props.field.filter
}
const res = await api.list(listQuery)
const list = res.list || res as any
options.value = list.map((item: any) => ({
label: item[props.field.relation.labelKey],
value: String(item._id),
}))
} catch (e) {
console.warn('[FieldRelation] search failed', e)
} finally {
loading.value = false
}
}
onMounted(async () => {
if ((props.mode === 'edit' || props.mode === 'filter') && props.field.relation) {
await onSearch('')
// 确保当前值在选项中
const currentValues = props.field.multiple
? (Array.isArray(props.modelValue) ? props.modelValue.map(String) : [])
: (props.modelValue ? [String(props.modelValue)] : [])
for (const val of currentValues) {
if (val && !options.value.some(o => o.value === val)) {
try {
const api = vueAdmin.getModule<ITableCrudProto<any>>(props.field.relation.moduleKey)
const current = await api.get(val)
if (current) {
options.value.unshift({
label: (current as any)[props.field.relation.labelKey],
value: String(val),
})
}
} catch { }
}
}
}
})
</script>
@@ -1,65 +0,0 @@
<template>
<template v-if="mode === 'display'">
<template v-if="Array.isArray(modelValue)"
>{{ displayLabels.join(', ') || '-' }}</template
>
<template v-else>{{ displayText }}</template>
</template>
<el-select
v-else-if="mode === 'filter'"
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
multiple
clearable
style="width: 100%"
>
<el-option v-for="opt in resolvedOptions" :key="opt.value" :label="vueAdmin.t(opt.label)" :value="opt.value" />
</el-select>
<el-select
v-else
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:disabled="disabled"
:multiple="field.multiple"
style="width: 100%"
>
<el-option v-for="opt in resolvedOptions" :key="opt.value" :label="vueAdmin.t(opt.label)" :value="opt.value" />
</el-select>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { vueAdmin } from '@/VueAdmin'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
const resolvedOptions = computed(() => {
const opts = props.field.options
if (typeof opts === 'string') {
return vueAdmin.globalOptions[opts] || []
}
return opts || []
})
const displayText = computed(() => {
const label = resolvedOptions.value.find((o: any) => o.value === props.modelValue)?.label
return label ? vueAdmin.t(label) : (props.modelValue ?? '-')
})
const displayLabels = computed(() => {
if (!Array.isArray(props.modelValue)) return []
return props.modelValue.map((val: any) => {
const opt = resolvedOptions.value.find((o: any) => o.value === val)
return opt ? vueAdmin.t(opt.label) : val
})
})
</script>
@@ -1,59 +0,0 @@
<template>
<template v-if="mode === 'display'">
<template v-if="Array.isArray(modelValue)">{{ modelValue.join(', ') || '-' }}</template>
<template v-else>{{ modelValue ?? '-' }}</template>
</template>
<div v-else-if="field.multiple" class="tags-input">
<el-tag v-for="(tag, index) in (modelValue || [])" :key="tag + index" closable @close="removeTag(index)">
{{ tag }}
</el-tag>
<el-input v-model="inputValue" @keyup.enter="addTag" @blur="addTag" :placeholder="$t('base.pressEnter')" />
</div>
<el-input v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" clearable />
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const inputValue = ref('')
function addTag() {
const val = inputValue.value.trim()
if (!val) return
const current = Array.isArray(props.modelValue) ? props.modelValue : []
emit('update:modelValue', [...current, val])
inputValue.value = ''
}
function removeTag(index: number) {
const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
current.splice(index, 1)
emit('update:modelValue', current)
}
</script>
<style scoped>
.tags-input {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
width: 100%;
}
.tags-input .el-input {
width: 120px;
flex: 1;
min-width: 120px;
}
</style>
@@ -1,120 +0,0 @@
<template>
<template v-if="mode === 'display'">
<span v-if="!modelValue || typeof modelValue !== 'object'">-</span>
<el-tooltip v-else :content="JSON.stringify(modelValue, null, 2)" placement="top">
<span class="tab-display">{{ displayText }}</span>
</el-tooltip>
</template>
<div v-else class="field-tab">
<el-form-item :label="vueAdmin.t(field.label) + ' - 类型'" :required="field.required">
<el-select :model-value="innerValue.type" @update:model-value="onTypeChange" :disabled="disabled">
<el-option v-for="tab in field.tabs" :key="tab.key" :label="vueAdmin.t(tab.label)" :value="tab.key" />
</el-select>
</el-form-item>
<div v-if="selectedTab?.fields?.length" class="tab-fields">
<template v-for="childField in selectedTab.fields" :key="childField.key">
<el-form-item :label="vueAdmin.t(childField.label)" :required="childField.required">
<component :is="vueAdmin.getFieldComponent(moduleKey, childField.type, childField.key)"
:field="childField"
:model-value="innerValue[childField.key]"
@update:model-value="onChildChange(childField.key, $event)"
mode="edit"
:disabled="disabled" />
</el-form-item>
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, inject, onMounted } from 'vue'
import { vueAdmin } from '@/VueAdmin'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const moduleKey = inject('shrimpModuleKey', '')
const displayText = computed(() => {
if (!props.modelValue || typeof props.modelValue !== 'object') return '-'
const type = props.modelValue.type
const tab = props.field.tabs?.find((t: any) => t.key === type)
return tab ? vueAdmin.t(tab.label) : String(type)
})
const selectedTab = computed(() => {
const type = props.modelValue?.type
return props.field.tabs?.find((t: any) => t.key === type)
})
const innerValue = computed(() => {
const val = props.modelValue
if (val != null && typeof val === 'object' && Object.keys(val).length > 0) {
return val
}
return initValue()
})
function initValue() {
const firstTab = props.field.tabs?.[0]
if (!firstTab) return {}
const obj: any = { type: firstTab.key }
firstTab.fields?.forEach((f: any) => {
if (f.defaultValue !== undefined) {
obj[f.key] = typeof f.defaultValue === 'function' ? f.defaultValue() : f.defaultValue
}
})
return obj
}
function onTypeChange(type: any) {
const tab = props.field.tabs?.find((t: any) => t.key === type)
const obj: any = { type }
tab?.fields?.forEach((f: any) => {
if (f.defaultValue !== undefined) {
obj[f.key] = typeof f.defaultValue === 'function' ? f.defaultValue() : f.defaultValue
}
})
emit('update:modelValue', obj)
}
function onChildChange(key: string, value: any) {
emit('update:modelValue', { ...innerValue.value, [key]: value })
}
onMounted(() => {
const val = props.modelValue
if (val == null || val === '' || (typeof val === 'object' && Object.keys(val).length === 0)) {
emit('update:modelValue', initValue())
}
})
</script>
<style scoped>
.tab-display {
display: inline-block;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.field-tab {
display: flex;
flex-direction: column;
gap: 8px;
}
.tab-fields {
padding-left: 16px;
border-left: 2px solid var(--el-border-color-lighter);
}
</style>
@@ -1,78 +0,0 @@
<template>
<template v-if="mode === 'display'">
<template v-if="Array.isArray(modelValue)">{{ modelValue.join('\n') || '-' }}</template>
<template v-else-if="!isLong">{{ text }}</template>
<div v-else class="textarea-cell">
<span class="text" :class="{ collapsed: !expanded }">{{ text }}</span>
<el-link type="primary" underline="never" class="toggle" @click.stop="expanded = !expanded">
{{ expanded ? $t('base.collapse') : $t('base.expand') }}
</el-link>
</div>
</template>
<template v-else-if="field.multiple">
<el-alert type="warning" :title="$t('table.textareaMultipleNotSupported')" :closable="false" />
</template>
<el-input
v-else
:type="mode === 'filter' ? 'text' : 'textarea'"
:rows="mode === 'filter' ? undefined : 3"
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
:disabled="disabled"
clearable
/>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit' | 'filter'
disabled?: boolean
displayLabel?: string
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
const text = computed(() => {
const v = props.displayLabel ?? props.modelValue
return v == null || v === '' ? '-' : String(v)
})
const isLong = computed(() => {
const s = text.value
return s.length > 40 || s.includes('\n')
})
const expanded = ref(false)
</script>
<style scoped>
.textarea-cell {
display: flex;
align-items: flex-start;
gap: 6px;
}
.text {
flex: 1;
white-space: pre-wrap;
word-break: break-all;
min-width: 0;
}
.text.collapsed {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
.toggle {
flex-shrink: 0;
font-size: 12px;
line-height: 1.5;
}
</style>
@@ -1,98 +0,0 @@
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export interface UseTableUrlSyncOptions {
pageSize: number
}
export function useTableUrlSync(options: UseTableUrlSyncOptions) {
const route = useRoute()
const router = useRouter()
const currentPage = ref(1)
const currentPageSize = ref(options.pageSize)
const keyword = ref('')
const searchKeyword = ref('')
const currentSortBy = ref('')
const currentSortOrder = ref<'asc' | 'desc'>('asc')
const filterData = ref<Record<string, any>>({})
function restoreFromQuery() {
const q = route.query
if (q.page) currentPage.value = Number(q.page) || 1
if (q.pageSize) currentPageSize.value = Number(q.pageSize) || options.pageSize
if (q.keyword) {
keyword.value = String(q.keyword)
searchKeyword.value = String(q.keyword)
}
if (q.sortBy) currentSortBy.value = String(q.sortBy)
if (q.sortOrder && (q.sortOrder === 'asc' || q.sortOrder === 'desc')) {
currentSortOrder.value = q.sortOrder
}
if (q.filter) {
try {
const parsed = JSON.parse(String(q.filter))
if (typeof parsed === 'object' && parsed !== null) {
filterData.value = parsed
}
} catch { /* ignore invalid JSON */ }
}
}
// 表格自身管理的 query key,sync 时只覆盖这几个,其它保留(如 ?id= 子表父记录、自定义参数)
const TABLE_QUERY_KEYS = ['page', 'pageSize', 'keyword', 'sortBy', 'sortOrder', 'filter']
function syncToQuery() {
const preserved: Record<string, any> = {}
for (const [k, v] of Object.entries(route.query)) {
if (!TABLE_QUERY_KEYS.includes(k)) preserved[k] = v
}
const query: Record<string, any> = { ...preserved }
if (currentPage.value > 1) query.page = String(currentPage.value)
if (currentPageSize.value !== options.pageSize) query.pageSize = String(currentPageSize.value)
if (searchKeyword.value) query.keyword = searchKeyword.value
if (currentSortBy.value) {
query.sortBy = currentSortBy.value
query.sortOrder = currentSortOrder.value
}
const cleanFilter: Record<string, any> = {}
for (const [key, val] of Object.entries(filterData.value)) {
if (val === '' || val === null || val === undefined) continue
if (Array.isArray(val) && val.length === 0) continue
cleanFilter[key] = val
}
if (Object.keys(cleanFilter).length > 0) {
query.filter = JSON.stringify(cleanFilter)
}
router.replace({ query })
}
let isRestoring = false
watch(
[currentPage, currentPageSize, searchKeyword, currentSortBy, currentSortOrder, filterData],
() => {
if (!isRestoring) {
syncToQuery()
}
},
{ deep: true }
)
function restore() {
isRestoring = true
restoreFromQuery()
isRestoring = false
}
return {
currentPage,
currentPageSize,
keyword,
searchKeyword,
currentSortBy,
currentSortOrder,
filterData,
restore,
}
}
@@ -1,42 +0,0 @@
// 配置可能有分以下几种 每种可能数据来源方式有所不同
// 程序配置
// 项目配置
// 客户配置
// 客户定义
// 临时选择
const DEFAULT_CONFIG = {
//追加其他头
HEADERS: {},
//版本号
APP_VER: "0.0.1",
//接口地址
API_URL: "/admin-api",
//请求超时
TIMEOUT: 10000,
//请求是否开启缓存
REQUEST_CACHE: false,
//是否加密localStorage, 为空不加密,可填写AES(模式ECB,移位Pkcs7)加密
LS_ENCRYPTION: '',
//localStorageAES加密秘钥,位数建议填写8的倍数
LS_ENCRYPTION_key: '2XNN4K8LC0ELVWN4',
}
const CONFIG = DEFAULT_CONFIG
export default CONFIG
@@ -1,131 +0,0 @@
{
"base": {
"name": "Name",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"search": "Search",
"filter": "Filter",
"reset": "Reset",
"confirm": "Confirm",
"cancel": "Cancel",
"close": "Close",
"save": "Save",
"submit": "Submit",
"operation": "Operation",
"exportExcel": "Export Excel",
"importExcel": "Import Excel",
"success": "Success",
"fail": "Failed",
"noNavMenu": "No submenu",
"clearCache": "Clear Cache",
"logout": "Logout",
"netError": "Network error",
"disabled": "Disabled",
"enabled": "Enabled",
"pressEnter": "Press Enter to add",
"expand": "Expand",
"collapse": "Collapse"
},
"user": {
"username": "Username",
"password": "Password",
"nickname": "Nickname",
"roleId": "Role",
"active": "Active",
"createdAt": "Created At",
"updatedAt": "Updated At",
"menuManage": "User",
"operationLog": "Operation Log"
},
"operationLog": {
"ip": "IP",
"method": "Action",
"args": "Args",
"success": "Success",
"errorMsg": "Error",
"createdAt": "Time"
},
"role": {
"name": "Role Name",
"description": "Description",
"isSuperAdmin": "Super Admin",
"powers": "Permissions",
"createdAt": "Created At",
"updatedAt": "Updated At",
"menuManage": "Role",
"menuList": "Role List",
"empty": "No roles",
"addRole": "Add Role",
"confirmDelete": "Are you sure you want to delete this role?",
"selectOrCreate": "Please select or create a role",
"selectAll": "Select All",
"newRole": "New Role"
},
"table": {
"addSuccess": "Added successfully",
"editSuccess": "Updated successfully",
"confirmDelete": "Are you sure you want to delete this record?",
"confirmClear": "Are you sure you want to clear all data? This cannot be undone!",
"yes": "Yes",
"no": "No",
"unlimited": "Any",
"startTime": "Start Time",
"endTime": "End Time",
"min": "Min",
"max": "Max",
"justNow": "Just now",
"minutesAgo": "{n} min ago",
"hoursAgo": "{n} hr ago",
"daysAgo": "{n} days ago",
"textareaMultipleNotSupported": "Textarea does not support multiple selection",
"passwordMultipleNotSupported": "Password does not support multiple selection",
"pressEnterForNumber": "Type number and press Enter"
},
"login": {
"userPlaceholder": "Username / Phone / Email",
"PWPlaceholder": "Please enter password",
"signIn": "Sign In",
"userError": "Please enter username",
"PWError": "Please enter password"
},
"power": {
"user": "User",
"role": "Role",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"list": "List",
"get": "Detail",
"importExcel": "Import Excel",
"exportExcel": "Export Excel",
"view": "View",
"clear": "Clear",
"export": "Export",
"import": "Import"
},
"shrimp": {
"loginFailed": "Incorrect username or password",
"userNotFound": "User not found",
"relogin": "Please log in again",
"userDisabled": "User has been disabled",
"emptyPath": "Request path cannot be empty",
"forbidden": "Insufficient permissions",
"serverError": "Server error",
"cannotDisableSelf": "Cannot disable yourself",
"cannotDeleteSelf": "Cannot delete yourself",
"cannotChangeOwnRole": "Cannot change your own role",
"cannotEditOwnRole": "Cannot edit your own role",
"cannotDeleteOwnRole": "Cannot delete your own role",
"cannotClearAllUsers": "Cannot clear all users",
"cannotClearAllRoles": "Cannot clear all roles",
"fieldRequired": "Field \"{field}\" is required",
"fieldTypeError": "Field \"{field}\" has invalid type",
"fieldInvalid": "Field \"{field}\" has invalid value",
"fieldMin": "Field \"{field}\" minimum value is {min}",
"fieldMax": "Field \"{field}\" maximum value is {max}",
"fieldMinLength": "Field \"{field}\" minimum length is {min}",
"fieldMaxLength": "Field \"{field}\" maximum length is {max}",
"invalidJson": "Invalid JSON format"
}
}
@@ -1,130 +0,0 @@
{
"base": {
"name": "名称",
"add": "新增",
"edit": "编辑",
"delete": "删除",
"search": "搜索",
"filter": "筛选",
"reset": "重置",
"confirm": "确定",
"cancel": "取消",
"close": "关闭",
"save": "保存",
"submit": "提交",
"operation": "操作",
"exportExcel": "导出Excel",
"importExcel": "导入Excel",
"success": "成功",
"fail": "失败",
"noNavMenu": "没有子菜单",
"clearCache": "清除缓存",
"logout": "退出登录",
"netError": "网络出错",
"disabled": "禁用",
"enabled": "启用",
"pressEnter": "按回车添加",
"back": "返回",
"refresh": "刷新",
"notFound": "未找到数据",
"expand": "展开",
"collapse": "收起"
},
"user": {
"username": "用户名",
"password": "密码",
"nickname": "昵称",
"roleId": "角色",
"active": "启用状态",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"operationLog": "操作日志"
},
"operationLog": {
"ip": "IP",
"method": "操作",
"args": "参数",
"success": "成功",
"errorMsg": "错误信息",
"createdAt": "时间"
},
"role": {
"name": "角色名",
"description": "描述",
"isSuperAdmin": "超级管理员",
"powers": "权限",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"menuList": "角色列表",
"empty": "暂无角色",
"addRole": "新增角色",
"confirmDelete": "确定删除此角色吗?",
"selectOrCreate": "请选择或新建角色",
"selectAll": "全选",
"newRole": "新角色"
},
"table": {
"addSuccess": "新增成功",
"editSuccess": "编辑成功",
"confirmDelete": "确定删除此记录吗?",
"confirmClear": "确定清空数据吗?此操作不可恢复!",
"yes": "是",
"no": "否",
"unlimited": "不限",
"startTime": "开始时间",
"endTime": "结束时间",
"min": "最小值",
"max": "最大值",
"justNow": "刚刚",
"minutesAgo": "{n} 分钟前",
"hoursAgo": "{n} 小时前",
"daysAgo": "{n} 天前",
"textareaMultipleNotSupported": "文本域暂不支持多选",
"passwordMultipleNotSupported": "密码暂不支持多选",
"pressEnterForNumber": "输入数字后按回车"
},
"login": {
"userPlaceholder": "用户名 / 手机 / 邮箱",
"PWPlaceholder": "请输入密码",
"signIn": "登录",
"userError": "请输入用户名",
"PWError": "请输入密码"
},
"power": {
"add": "新增",
"edit": "编辑",
"delete": "删除",
"list": "列表",
"get": "详情",
"importExcel": "导入Excel",
"exportExcel": "导出Excel",
"view": "查看",
"clear": "清除",
"export": "导出",
"import": "导入"
},
"shrimp": {
"loginFailed": "用户名或密码错误",
"userNotFound": "用户不存在",
"relogin": "请重新登录",
"userDisabled": "用户已被禁用",
"emptyPath": "请求路径不能为空",
"forbidden": "权限不足",
"serverError": "服务器错误",
"cannotDisableSelf": "不能禁用自己",
"cannotDeleteSelf": "不能删除自己",
"cannotChangeOwnRole": "不能修改自己的角色",
"cannotEditOwnRole": "不能编辑自己的角色",
"cannotDeleteOwnRole": "不能删除自己的角色",
"cannotClearAllUsers": "不能清空所有用户",
"cannotClearAllRoles": "不能清空所有角色",
"fieldRequired": "字段\"{field}\"不能为空",
"fieldTypeError": "字段\"{field}\"类型错误",
"fieldInvalid": "字段\"{field}\"值不合法",
"fieldMin": "字段\"{field}\"最小值为 {min}",
"fieldMax": "字段\"{field}\"最大值为 {max}",
"fieldMinLength": "字段\"{field}\"最小长度为 {min}",
"fieldMaxLength": "字段\"{field}\"最大长度为 {max}",
"invalidJson": "JSON格式错误"
}
}
-10
View File
@@ -1,10 +0,0 @@
import { createApp } from 'vue'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/display.css'
import App from './views/App.vue'
import vueAdmin from './VueAdmin'
const app = createApp(App)
app.use(vueAdmin)
app.mount('#app')
@@ -1,42 +0,0 @@
<template>
<template v-if="mode === 'display'">
<el-tooltip :content="fullTime" placement="top">
<span>{{ relativeTime }}</span>
</el-tooltip>
</template>
<el-date-picker v-else :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" :disabled="disabled" type="datetime" style="width: 100%" />
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { vueAdmin } from '@/VueAdmin'
const { t } = useI18n()
const props = defineProps<{
field: any
modelValue: any
mode: 'display' | 'edit'
disabled?: boolean
}>()
defineEmits<{
'update:modelValue': [value: any]
}>()
const fullTime = computed(() => {
if (!props.modelValue) return '-'
return new Date(props.modelValue).toLocaleString(vueAdmin.locale === 'en' ? 'en-US' : 'zh-CN')
})
const relativeTime = computed(() => {
if (!props.modelValue) return '-'
const diff = Date.now() - props.modelValue
if (diff < 60000) return t('table.justNow')
if (diff < 3600000) return t('table.minutesAgo', { n: Math.floor(diff / 60000) })
if (diff < 86400000) return t('table.hoursAgo', { n: Math.floor(diff / 3600000) })
if (diff < 2592000000) return t('table.daysAgo', { n: Math.floor(diff / 86400000) })
return fullTime.value
})
</script>
@@ -1,7 +0,0 @@
<template>
<ShrimpTable moduleKey="product" />
</template>
<script lang="ts" setup>
import ShrimpTable from '@/components/ShrimpTable.vue'
</script>
@@ -1,238 +0,0 @@
<template>
<el-container class="role-page">
<!-- 左侧:角色列表 -->
<el-aside width="260px">
<div class="role-list-card">
<div class="role-list-header">{{ $t('role.menuList') }}</div>
<div class="role-list-body">
<div v-for="role in roleList" :key="role._id" class="role-item"
:class="{ active: activeId === role._id }" @click="onSelect(role._id)">
{{ role.name }}
</div>
<div v-if="roleList.length === 0" class="empty-tip">{{ $t('role.empty') }}</div>
</div>
<div class="role-list-footer" v-if="can('add')">
<el-button type="primary" @click="handleAdd" class="add-btn">{{ $t('role.addRole') }}</el-button>
</div>
</div>
</el-aside>
<!-- 右侧:角色编辑 -->
<el-main>
<div v-if="currentRole" class="role-form">
<el-form :model="formData" label-width="80px">
<el-form-item :label="$t('role.name')">
<el-input v-model="formData.name" />
</el-form-item>
<el-form-item :label="$t('role.description')">
<el-input v-model="formData.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item :label="$t('role.powers')">
<PowerTree :modelValue="formData.powers || []" :allPowers="allPowers" :menuTree="vueAdmin.menuTree"
@update:modelValue="val => formData.powers = val" />
</el-form-item>
</el-form>
<div class="form-footer">
<el-button v-if="can('edit')" type="primary" @click="handleSave">{{ $t('base.save') }}</el-button>
<el-popconfirm v-if="can('delete')" :title="$t('role.confirmDelete')" :confirm-button-text="$t('base.delete')"
:cancel-button-text="$t('base.cancel')" confirm-button-type="danger" @confirm="handleDelete">
<template #reference>
<el-button type="danger">{{ $t('base.delete') }}</el-button>
</template>
</el-popconfirm>
</div>
</div>
<el-empty v-else :description="$t('role.selectOrCreate')" />
</el-main>
</el-container>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { vueAdmin } from '@/VueAdmin'
import { IRole, IRoleProto, WithId, ILabel } from '@shrimp/IShrimpType'
import { ElMessage } from 'element-plus'
import PowerTree from '@/components/PowerTree.vue'
interface IPowerItem {
key: string
label: ILabel
}
const { t } = useI18n()
const props = defineProps<{
moduleKey: string
events?: string[]
}>()
const roleApi = vueAdmin.getModule<IRoleProto>('role')
const roleList = ref<WithId<IRole>[]>([])
const activeId = ref('')
const allPowers = ref<IPowerItem[]>([])
const currentRole = ref<WithId<IRole> | null>(null)
const formData = ref<Partial<Omit<IRole, 'createdAt' | 'updatedAt'>>>({
name: '',
description: '',
powers: [],
})
const can = vueAdmin.can('role')
async function loadRoles() {
const res = await roleApi.list()
roleList.value = res.list || res as any
}
function onSelect(id: string) {
activeId.value = id
const role = roleList.value.find(r => r._id === id)
if (role) {
currentRole.value = { ...role }
formData.value = {
name: role.name,
description: role.description,
powers: [...(role.powers || [])],
}
}
}
async function handleAdd() {
try {
const created = await roleApi.add({ name: t('role.newRole'), description: '', powers: [] } as any)
await loadRoles()
onSelect(created._id)
} catch (e: any) {
console.warn('[Role]', e.message)
}
}
async function handleSave() {
try {
await roleApi.edit(currentRole.value!._id, formData.value)
await loadRoles()
onSelect(currentRole.value!._id)
ElMessage.success(t('base.success'))
} catch (e: any) {
console.warn('[Role]', e.message)
}
}
async function handleDelete() {
try {
await roleApi.delete(currentRole.value!._id)
currentRole.value = null
activeId.value = ''
await loadRoles()
if (roleList.value.length > 0) {
onSelect(roleList.value[0]._id)
}
} catch (e: any) {
console.warn('[Role]', e.message)
}
}
onMounted(async () => {
allPowers.value = await roleApi.allPower()
await loadRoles()
if (roleList.value.length > 0) {
onSelect(roleList.value[0]._id)
}
})
</script>
<style scoped>
.role-page {
height: 100%;
padding: 16px;
}
.el-aside {
margin-right: 16px;
border: none;
}
@media (max-width: 992px) {
.el-aside {
margin-right: 0;
}
.role-list-card {
margin-bottom: 16px;
}
}
/* 角色列表卡片 */
.role-list-card {
border: 1px solid var(--el-border-color);
border-radius: 8px;
background: var(--el-bg-color);
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.role-list-header {
padding: 12px 16px;
font-weight: 600;
font-size: 14px;
border-bottom: 1px solid var(--el-border-color);
}
.role-list-body {
flex: 1;
overflow-y: auto;
}
.role-item {
padding: 10px 16px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
border-bottom: 1px solid var(--el-border-color-extra-light);
}
.role-item:hover {
background: var(--el-fill-color-light);
}
.role-item.active {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
font-weight: 500;
}
.role-list-footer {
border-top: 1px solid var(--el-border-color);
}
.add-btn {
width: 100%;
height: 40px;
border-radius: 0 0 8px 8px;
border: none !important;
font-size: 14px;
}
/* 角色表单 */
.role-form {
max-width: 700px;
}
.form-footer {
padding-top: 16px;
border-top: 1px solid var(--el-border-color);
margin-top: 16px;
}
.empty-tip {
padding: 20px;
text-align: center;
color: var(--el-text-color-placeholder);
font-size: 14px;
}
</style>
@@ -1,7 +0,0 @@
<template>
<ShrimpTable moduleKey="tag" />
</template>
<script lang="ts" setup>
import ShrimpTable from '@/components/ShrimpTable.vue'
</script>
@@ -1,14 +0,0 @@
<template>
<ShrimpTable moduleKey="user" />
</template>
<script lang="ts" setup>
import ShrimpTable from '@/components/ShrimpTable.vue'
import { vueAdmin } from '@/VueAdmin'
import FieldRelativeTime from './FieldRelativeTime.vue'
vueAdmin.overrideField('user', 'updatedAt', FieldRelativeTime)
</script>
<style scoped>
</style>
@@ -1,67 +0,0 @@
<template>
<div class="op-log-page">
<div v-if="user" class="page-header">
<span class="page-subtitle">{{ user.nickname || user.username }}</span>
</div>
<div class="page-body">
<ShrimpTable v-if="userId" :key="userId" moduleKey="operationLog" />
<el-empty v-else :description="t('base.notFound')" />
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import ShrimpTable from '@/components/ShrimpTable.vue'
import { vueAdmin } from '@/VueAdmin'
const route = useRoute()
const { t } = useI18n()
const userId = computed(() => route.query.id as string)
const user = ref<any>(null)
async function loadUser() {
if (!userId.value) {
user.value = null
return
}
try {
const api = vueAdmin.getModule<any>('user')
user.value = await api.get(userId.value)
} catch (e: any) {
console.warn('[operationLog] load user failed', e?.message || e)
}
}
watch(userId, loadUser)
onMounted(loadUser)
</script>
<style scoped>
.op-log-page {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.page-header {
padding: 8px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
}
.page-subtitle {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.page-body {
flex: 1;
overflow: auto;
min-height: 0;
}
</style>
-521
View File
@@ -1,521 +0,0 @@
/* 全局 */
#app,
body,
html {
width: 100%;
height: 100%;
background-color: #f6f8f9;
font-size: 12px;
font-family: "sans-serif";
}
a {
color: #333;
text-decoration: none;
}
a:hover,
a:focus {
color: #000;
text-decoration: none;
}
a:link {
text-decoration: none;
}
a:-webkit-any-link {
text-decoration: none;
}
a,
button,
input,
textarea {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
box-sizing: border-box;
outline: none !important;
-webkit-appearance: none;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
outline: none;
}
/* 大布局样式 */
.aminui {
display: flex;
flex-flow: column;
}
.aminui-wrapper {
display: flex;
flex: 1;
overflow: auto;
}
/* 全局滚动条样式 */
.scrollabel {
-webkit-overflow-scrolling: touch;
}
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-thumb {
background-color: rgba(50, 50, 50, 0.3);
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(50, 50, 50, 0.6);
}
::-webkit-scrollbar-track {
background-color: rgba(50, 50, 50, 0.1);
}
::-webkit-scrollbar-track:hover {
background-color: rgba(50, 50, 50, 0.2);
}
/*布局设置*/
.layout-setting {
position: fixed;
width: 40px;
height: 40px;
border-radius: 3px 0 0 3px;
bottom: 100px;
right: 0px;
z-index: 100;
background: #409EFF;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
}
.layout-setting i {
font-size: 18px;
color: #fff;
}
/* 头部 */
.adminui-header {
height: 58px;
background: #222b45;
color: #fff;
display: flex;
justify-content: space-between;
}
.adminui-header-left {
display: flex;
align-items: center;
padding-left: 20px;
}
.adminui-header-right {
display: flex;
align-items: center;
}
.adminui-header .logo-bar {
font-size: 20px;
font-weight: bold;
display: flex;
align-items: center;
}
.adminui-header .logo-bar .logo {
margin-right: 10px;
width: 35px;
height: 35px;
}
.adminui-header .nav {
display: flex;
height: 100%;
margin-left: 40px;
}
.adminui-header .nav li {
padding: 0 10px;
margin: 0 10px 0 0;
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
list-style: none;
height: 100%;
display: flex;
align-items: center;
cursor: pointer;
}
.adminui-header .nav li i {
margin-right: 5px;
}
.adminui-header .nav li:hover {
color: #fff;
}
.adminui-header .nav li.active {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.adminui-header .user-bar .panel-item:hover {
background: rgba(255, 255, 255, 0.1) !important;
}
.adminui-header .user-bar .user label {
color: #fff;
}
/* 左侧菜单 */
.aminui-side-split {
width: 65px;
flex-shrink: 0;
background: #222b45;
display: flex;
flex-flow: column;
}
.aminui-side-split-top {
height: 49px;
}
.aminui-side-split-top a {
display: inline-block;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.aminui-side-split-top .logo {
height: 30px;
vertical-align: bottom;
}
.adminui-side-split-scroll {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
flex: 1;
}
.aminui-side-split li {
cursor: pointer;
width: 65px;
height: 65px;
color: #fff;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.aminui-side-split li i {
font-size: 18px;
}
.aminui-side-split li p {
margin-top: 5px;
padding: 0 4px;
font-size: 12px;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.aminui-side-split li:hover {
background: rgba(255, 255, 255, 0.1);
}
.aminui-side-split li.active {
background: #409EFF;
}
.adminui-side-split-scroll::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.4);
border-radius: 5px;
}
.adminui-side-split-scroll::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.5);
}
.adminui-side-split-scroll::-webkit-scrollbar-track {
background-color: rgba(255, 255, 255, 0);
}
.adminui-side-split-scroll::-webkit-scrollbar-track:hover {
background-color: rgba(255, 255, 255, 0);
}
.aminui-side {
display: flex;
flex-flow: column;
flex-shrink: 0;
width: 210px;
background: #fff;
box-shadow: 2px 0 8px 0 rgba(29, 35, 41, .05);
border-right: 1px solid #e6e6e6;
transition: width 0.3s;
}
.adminui-side-top {
border-bottom: 1px solid #ebeef5;
height: 50px;
line-height: 50px;
}
.adminui-side-top h2 {
padding: 0 20px;
font-size: 17px;
color: #3c4a54;
}
.adminui-side-scroll {
overflow: auto;
overflow-x: hidden;
flex: 1;
}
.adminui-side-bottom {
border-top: 1px solid #ebeef5;
height: 51px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.adminui-side-bottom i {
font-size: 16px;
}
.adminui-side-bottom:hover {
color: var(--el-color-primary);
}
.aminui-side.isCollapse {
width: 65px;
}
.el-menu .menu-tag {
position: absolute;
height: 18px;
line-height: 18px;
background: var(--el-color-danger);
font-size: 12px;
color: #fff;
right: 20px;
border-radius: 18px;
padding: 0 6px;
}
.el-menu .el-sub-menu__title .menu-tag {
right: 40px;
}
.el-menu--horizontal>li .menu-tag {
display: none;
}
/* 右侧内容 */
.aminui-body {
flex: 1;
display: flex;
flex-flow: column;
}
.adminui-topbar {
height: 50px;
border-bottom: 1px solid #ebeef5;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, .08);
display: flex;
justify-content: space-between;
}
.adminui-topbar .left-panel {
display: flex;
align-items: center;
}
.adminui-topbar .right-panel {
display: flex;
align-items: center;
}
.right-panel-search {
display: flex;
align-items: center;
}
.right-panel-search>*+* {
margin-left: 10px;
}
.adminui-tags {
height: 35px;
background: #fff;
border-bottom: 1px solid #e6e6e6;
}
.adminui-tags ul {
display: flex;
overflow: hidden;
}
.adminui-tags li {
cursor: pointer;
display: inline-block;
float: left;
height: 34px;
line-height: 34px;
position: relative;
flex-shrink: 0;
}
.adminui-tags li::after {
content: " ";
width: 1px;
height: 100%;
position: absolute;
right: 0px;
background-image: linear-gradient(#fff, #e6e6e6);
}
.adminui-tags li a {
display: inline-block;
padding: 0 10px;
width: 100%;
height: 100%;
color: #999;
text-decoration: none;
display: flex;
align-items: center;
}
.adminui-tags li i {
margin-left: 10px;
border-radius: 3px;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.adminui-tags li i:hover {
background: rgba(0, 0, 0, .2);
color: #fff;
}
.adminui-tags li:hover {
background: #ecf5ff;
}
.adminui-tags li.active {
background: #409EFF;
}
.adminui-tags li.active a {
color: #fff;
}
.adminui-tags li.sortable-ghost {
opacity: 0;
}
.adminui-main {
overflow: hidden;
background-color: #f6f8f9;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* 让路由内容铺满 .adminui-main,使内部的滚动容器(如 ShrimpTable 的 .el-main)能拿到正确高度 */
.adminui-main > * {
flex: 1;
min-height: 0;
}
/*页面最大化*/
.aminui.main-maximize {
.main-maximize-exit {
display: block;
}
.aminui-side-split,
.aminui-side,
.adminui-header,
.adminui-topbar,
.adminui-tags {
display: none;
}
}
.main-maximize-exit {
display: none;
position: fixed;
z-index: 3000;
top: -20px;
left: 50%;
margin-left: -20px;
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
background: rgba(0, 0, 0, 0.2);
text-align: center;
}
.main-maximize-exit i {
font-size: 14px;
margin-top: 22px;
color: #fff;
}
.main-maximize-exit:hover {
background: rgba(0, 0, 0, 0.4);
}
/*定宽页面*/
.sc-page {
width: 1230px;
margin: 0 auto;
}
.el-drawer .el-form-item {
display: block;
}
// .el-drawer.rtl {width: min(600px, 100%)!important;}
//新增全局字体样式
.el-tag+.el-tag {
margin-left: 5px;
}
.el-button+.el-button {
margin-left: 5px;
}
.el-table .el-table__cell {
padding: 5px;
}
@@ -1,42 +0,0 @@
@use 'element-plus/theme-chalk/src/dark/css-vars.scss';
html.dark {
//变量
--el-text-color-primary: #d0d0d0;
--el-color-primary-dark-2: var(--el-color-primary-light-2) !important;
--el-color-primary-light-9: var(--el-color-primary-dark-8) !important;
--el-color-primary-light-8: var(--el-color-primary-dark-7) !important;
--el-color-primary-light-7: var(--el-color-primary-dark-6) !important;
--el-color-primary-light-5: var(--el-color-primary-dark-4) !important;
--el-color-primary-light-3: var(--el-color-primary-dark-3) !important;
//背景
#app {background: var(--el-bg-color);}
//登录背景
.login_bg {background: var(--el-bg-color);}
//框架
.adminui-header {background: var(--el-bg-color-overlay);border-bottom: 1px solid var(--el-border-color-light);height:59px;}
.aminui-side-split {background: var(--el-bg-color);}
.aminui-side-split li {color: var(--el-text-color-primary);}
.aminui-side {background: var(--el-bg-color-overlay);border-color: var(--el-border-color-light);}
.adminui-side-top, .adminui-side-bottom {border-color: var(--el-border-color-light);}
.adminui-side-top h2 {color: var(--el-text-color-primary);}
.adminui-topbar, .adminui-tags {background: var(--el-bg-color-overlay);border-color: var(--el-border-color-light);}
.adminui-main {background: var(--el-bg-color);}
.drawerBG {background: var(--el-bg-color);}
.adminui-header-menu .el-menu {--el-menu-bg-color:var(--el-bg-color-overlay) !important;--el-menu-hover-bg-color: #171819 !important;}
.adminui-header-menu .el-menu .el-sub-menu__title {background-color:transparent !important;}
//全局滚动条样式
::-webkit-scrollbar-thumb {background-color: rgba(163, 166, 173, 0.3);}
::-webkit-scrollbar-thumb:hover {background-color: rgba(163, 166, 173, 0.5);}
//组件
.el-header, .el-main.nopadding, .el-footer {background: var(--el-bg-color-overlay);border-color: var(--el-border-color-light);}
.el-main {background: var(--el-bg-color);}
.el-aside {background: var(--el-bg-color-overlay);border-color: var(--el-border-color-light);}
.el-table .el-table__body-wrapper {background: var(--el-bg-color);}
.el-table th.is-sortable:hover {background: #111;}
}
@@ -1,91 +0,0 @@
/* 覆盖element-plus样式 */
:root {
--el-color-primary: #409EFF;
--el-color-primary-light-1: #53a7ff;
--el-color-primary-light-2: #66b1ff;
--el-color-primary-light-3: #79bbff;
--el-color-primary-light-4: #8cc4ff;
--el-color-primary-light-5: #9fceff;
--el-color-primary-light-6: #b2d8ff;
--el-color-primary-light-7: #c5e1ff;
--el-color-primary-light-8: #d8ebff;
--el-color-primary-light-9: #ebf5ff;
--el-color-primary-dark-1: #398ee5;
--el-color-primary-dark-2: #337ecc;
--el-color-primary-dark-3: #2c6eb2;
--el-color-primary-dark-4: #265e99;
--el-color-primary-dark-5: #204f7f;
--el-color-primary-dark-6: #193f66;
--el-color-primary-dark-7: #132f4c;
--el-color-primary-dark-8: #0c1f32;
--el-color-primary-dark-9: #060f19;
}
.el-menu {border: none!important;}
.el-menu .el-menu-item a {color: inherit;text-decoration: none;display: block;width:100%;height:100%;position: absolute;top:0px;left:0px;}
.el-form-item-msg {font-size: 12px;color: #999;clear: both;width: 100%;}
.el-container {height: 100%;}
.el-aside {border-right: 1px solid var(--el-border-color-light);}
.el-container + .el-aside {border-right: 0;border-left: 1px solid var(--el-border-color-light);}
.el-header {background: #fff;border-bottom: 1px solid var(--el-border-color-light);padding:13px 15px;display: flex;align-items: center;}
.el-header .left-panel {display: flex;align-items: center;}
.el-header .right-panel {display: flex;align-items: center;}
.el-header .right-panel > * + * {margin-left:10px;}
.el-footer {background: #fff;border-top: 1px solid var(--el-border-color-light);padding:13px 15px;}
.el-main {padding:15px;}
.el-main.nopadding {padding:0;background: #fff;}
.el-drawer__body {overflow: auto;padding:0;}
.el-popconfirm__main {margin: 14px 0;}
// .el-card__header {border-bottom: 0;font-size: 17px;font-weight: bold;padding:15px 20px 0px 20px;}
// .el-card__header {padding:5px 20px 5px 20px;}
.el-dialog__title {font-size: 17px;font-weight: bold;}
.el-drawer__header>:first-child {font-size: 17px;font-weight: bold;}
.el-tree.menu .el-tree-node__content {height:36px;}
.el-tree.menu .el-tree-node__content .el-tree-node__label .icon {margin-right: 5px;}
.el-progress__text {font-size: 12px!important;}
.el-progress__text i {font-size: 14.4px!important;}
.el-step.is-horizontal .el-step__line {height:1px;}
.el-step__title {font-size: 14px;}
.drawerBG {background: #f6f8f9;}
.el-button+.el-dropdown {margin-left: 10px;}
.el-button-group+.el-dropdown {margin-left: 10px;}
.el-button-group+.el-button-group {margin-left: 10px;}
.el-tabs__nav-wrap::after {height: 1px;}
.el-table th.is-sortable {transition: .1s;}
.el-table th.is-sortable:hover {background: #eee;}
.el-table .el-table__body-wrapper {background: #f6f8f9;}
.el-col .el-card {margin-bottom: 15px;}
.el-main {flex-basis: 100%;}
.el-main > .scTable .el-table--border::before {display: none;}
.el-main > .scTable .el-table--border::after {display: none;}
.el-main > .scTable .el-table--border .el-table__inner-wrapper::after {display: none;}
.el-main > .scTable .el-table__border-left-patch {display: none;}
.el-main > .scTable .el-table--border .el-table__inner-wrapper tr:first-child td:first-child {border-left: 0;}
.el-main > .scTable .el-table--border .el-table__inner-wrapper tr:first-child th:first-child {border-left: 0;}
.el-table.el-table--large {font-size: 14px;}
.el-table.el-table--small {font-size: 12px;}
.el-table {font-size: 12px;}
.el-radio-button__inner {font-size: 12px;}
.el-checkbox-button__inner {font-size: 12px;}
.el-sub-menu .el-icon {font-size: 17px;}
.el-sub-menu .el-sub-menu__icon-arrow {font-size: 12px;}
.aminui-side-split li.active {background-color: var(--el-color-primary);}
.adminui-tags li:hover {background-color: var(--el-color-primary-light-9);}
.adminui-tags li.active {background-color: var(--el-color-primary)!important;}
.contextmenu li:hover {background-color: var(--el-color-primary-light-9)!important;color: var(--el-color-primary-light-2)!important;}
.data-box .item-background {background-color: var(--el-color-primary)!important;}
.layout-setting,.diy-grid-setting {background-color: var(--el-color-primary)!important;}
/* 全部禁用el-tag动画 */
.el-tag {transition: all 0s !important;}
/* 覆盖tinymce样式 */
.sceditor .tox-tinymce {border: 1px solid #DCDFE6;border-radius: 0;}
body .tox-tinymce-aux {z-index: 5700;}
//新增
.el-table .cell{
word-break: break-word;
}
@@ -1,174 +0,0 @@
@media (max-width: 992px) {
// 移动端样式覆盖
.el-form-item {
display: block;
}
.el-form-item__label {
margin: 5px !important;
}
.el-dialog {
width: 90% !important;
}
.el-dialog.is-fullscreen {
width: 100% !important;
}
.el-drawer.rtl {
width: min(400px, 100%) !important;
}
.el-form-item__content {
margin: 5px !important;
}
// .arrayInput{
// width: 100% !important;
// }
.adminui-main {
>.el-container {
display: block;
height: auto;
}
>.el-container>.el-aside {
width: 100% !important;
border: 0
}
}
.scTable {
.el-table,
.el-table__body-wrapper {
display: block !important;
height: auto !important;
}
.el-scrollbar__wrap {
height: auto !important;
}
.scTable-page {
padding: 0 5px !important;
}
.el-pagination__total,
.el-pagination__jump,
.el-pagination__sizes {
display: none !important;
}
}
.headerPublic {
height: auto !important;
display: block;
.left-panel {
overflow: auto;
}
.left-panel::-webkit-scrollbar {
display: none;
}
.right-panel {
display: block;
border-top: 1px solid var(--el-border-color-light);
margin-top: 15px;
}
.right-panel .right-panel-search {
display: block;
}
.right-panel .right-panel-search>* {
width: 100%;
margin: 0;
margin-top: 15px;
}
}
.adminui-main>.el-container>*:first-child:not(.el-aside):not(.el-header) {
border: 0;
margin-top: 0;
}
.adminui-main>.el-container>*:first-child:not(.el-aside):not(.el-header)+.el-aside {
margin-top: 0;
}
.adminui-main>.el-container>.el-aside {
border-bottom: 1px solid var(--el-border-color-light) !important;
}
.adminui-main>.el-container>.el-container {
border-top: 1px solid var(--el-border-color-light);
border-bottom: 1px solid var(--el-border-color-light);
margin-top: 15px;
}
.adminui-main>.el-container>.el-container+.el-aside {
border-top: 1px solid var(--el-border-color-light);
margin-top: 15px;
}
.adminui-main>.el-container>.el-header {
@extend .headerPublic;
}
.adminui-main>.el-container>.el-main.nopadding {
border-top: 1px solid var(--el-border-color-light);
border-bottom: 1px solid var(--el-border-color-light);
margin-top: 15px;
}
.adminui-main>.el-container>.el-main+.el-aside {
border-left: 0 !important;
border-top: 1px solid var(--el-border-color-light);
margin-top: 15px;
}
.adminui-main>.el-container>.el-footer {
margin-top: 15px;
border-bottom: 1px solid var(--el-border-color-light);
}
.adminui-main>.el-container>.el-container>.el-header {
@extend .headerPublic
}
.adminui-main>.el-container>.el-container>.el-header .left-panel {
display: block;
}
.adminui-main>.el-container>.el-container>.el-header .right-panel {
display: block;
margin-top: 15px;
}
.sc-page {
width: 100%;
margin: 0;
}
.common-main .el-form {
width: 100% !important;
}
.common-header-logo label {
display: none;
}
.common-header-title {
display: none;
}
//手机端没有左右边距 新增
.el-main {
padding: 15px 0px;
}
}
@@ -1,44 +0,0 @@
/* USERCENTER */
.page-user {
.user-info-top {text-align: center;}
.user-info-top h2 {font-size: 18px;margin-top: 5px;}
.user-info-top p {margin: 8px 0 10px 0;}
.menu {background: none;}
.menu .el-menu-item {font-size: 12px;--el-menu-item-height:50px;}
.menu .el-menu-item-group {border-top: 1px solid var(--el-border-color-light);}
.menu .el-menu-item-group:first-child {border: 0;}
}
/*static-table*/
.static-table {border-collapse: collapse;width: 100%;font-size: 14px;margin-bottom: 45px;line-height: 1.5em;}
.static-table th {text-align: left;white-space: nowrap;color: #909399;font-weight: 400;border-bottom: 1px solid #dcdfe6;padding: 15px;max-width: 250px;}
.static-table td {border-bottom: 1px solid #dcdfe6;padding: 15px;max-width: 250px;color: #606266;}
/*header-tabs*/
.header-tabs {padding:10px 0 0 0;display:block;border:0!important;height:50px;background: none;}
.header-tabs .el-tabs__header {padding-left:10px;margin: 0;}
.header-tabs .el-tabs__content {display: none;}
.header-tabs .el-tabs__nav {border-radius: 0 !important;}
.header-tabs .el-tabs__item {font-size: 13px;}
.header-tabs .el-tabs__item.is-active {background-color: var(--el-bg-color-overlay);}
/*common-page*/
.common-page {}
.common-header-left {display: flex;align-items: center;}
.common-header-logo {display: flex;align-items: center;}
.common-header-logo img {height:30px;margin-right: 10px;vertical-align: bottom;}
.common-header-logo label {font-size: 20px;}
.common-header-title {font-size: 16px;border-left: 1px solid var(--el-border-color-light);margin-left: 15px;padding-left: 15px;}
.common-header-right {display: flex;align-items: center;}
.common-header-right a {font-size: 14px;color: var(--el-color-primary);cursor: pointer;}
.common-header-right a:hover {color: var(--el-color-primary-light-3);}
.common-container {max-width: 1240px;margin:30px auto 30px auto;}
.common-main {padding:20px;}
.common-title {font-size: 26px;margin-bottom: 20px;font-weight: normal;}
.common-main .el-form {width: 500px;margin:30px auto;}
.common-main .el-steps .el-step__title {font-size: 14px;}
.common-main .el-steps .el-step__icon {border: 1px solid;}
.common-main .yzm {display: flex;width: 100%;}
.common-main .yzm .el-button {margin-left: 10px;}
.common-main .link {color: var(--el-color-primary);cursor: pointer;}
.common-main .link:hover {color: var(--el-color-primary-light-3);}
@@ -1,6 +0,0 @@
@use 'app.scss';
@use 'fix.scss';
@use 'pages.scss';
@use 'media.scss';
@use 'dark.scss';
@use 'zd.scss';
@@ -1,48 +0,0 @@
.zd-flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.zd-flex-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.zd-flex-column {
display: flex;
flex-direction: column;
}
.zd-padding-0 {
padding: 0px;
}
.zd-padding-1 {
padding: 1px;
}
.zd-padding-2 {
padding: 2px;
}
.zd-padding-3 {
padding: 3px;
}
.zd-padding-5 {
padding: 5px;
}
.zd-padding-8 {
padding: 8px;
}
.zd-padding-10 {
padding: 10px;
}
.zd-aside+.zd-aside {
margin-left: 10px;
}
-110
View File
@@ -1,110 +0,0 @@
export enum GameState {
READYING,
PLAYING,
ENDING,
}
export enum RoomState {
WAITING,
READYING,
PLAYING,
ENDING,
}
export enum PlayerState {
ALIVE,
DEAD,
SUCCESS,
}
export interface IPlayer {
name: string
uid: string
sid: string
playerId: string
vip: number
face: string
score: number
exCount: {
bag: number
save: number
}
}
export interface IThingVo {
thingId: string
configId: string
}
export interface IBagVO {
things: IThingVo[]
count: number
addCount: number
}
export interface ITeamInfo {
playerInfo: IPlayer
team: {
teamId: number
birthplace: number
exitPlane: number
roomId: number
sitId: number
isOnline: boolean
state: PlayerState
}
bag: IBagVO
saveBag: IBagVO
}
export interface IRoomInfo {
birthplaceList: { b: number; e: number }[]
index: number
state: RoomState
lockInfo: { [key: string]: boolean }
}
export interface IRoomsInfo {
rooms: IRoomInfo[]
roomIndex: number
endTime: number
}
export interface IBaseInfo {
playerId?: string
bag?: string
clothingId?: number
}
export interface IBaseSC {
baseInfo(value: IBaseInfo): void
upLockSit(roomId: number, index: number, value: boolean): void
upRoom(value: IRoomsInfo): void
addUser(value: ITeamInfo): void
removeUser(value: ITeamInfo): void
playerInfo(value: ITeamInfo): void
upPlayer(value: ITeamInfo): void
kick(): void
eliminate(): void
addThing(value: IThingVo[]): void
addSaveThing(value: IThingVo[]): void
removeThing(value: number): void
}
export interface IBossRouter {
iamboss(): Promise<void>
changeSit(uid: string, sitId: number, teamId: number, roomIndex?: number): Promise<void>
lockSit(roomId: number, index: number, lock: boolean): Promise<void>
kill(uid: string): Promise<void>
ready(): Promise<void>
start(): Promise<void>
end(): Promise<void>
}
export interface LoginVo {
userId: string
}
export interface IWebCS {
getToken(vo: LoginVo): string
}
@@ -1,10 +0,0 @@
<template>
<div>
<h1>404</h1>
</div>
</template>
<script lang="ts" setup>
</script>
<style scoped></style>
@@ -1,24 +0,0 @@
<template>
<el-config-provider :locale="locale">
<router-view></router-view>
</el-config-provider>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { vueAdmin } from "../VueAdmin";
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
const locale = computed(() => {
let curLang = (vueAdmin.i18n.global.locale as any).value
if (curLang == "en") {
return en;
} else {
return zhCn;
}
});
</script>
<style lang="scss">
@use "../style/style.scss";
</style>
@@ -1,110 +0,0 @@
<template>
<section class="aminui-wrapper">
<div v-if="!vueAdmin.state.isMobile" class="aminui-side-split">
<div class="aminui-side-split-top">
<logo />
</div>
<div class="adminui-side-split-scroll">
<div class="sidebar-menu-wrap">
<el-scrollbar>
<ul>
<li v-for="item in vueAdmin.menuTree"
:class="pmenu?.name == item.name ? 'active' : ''"
@click="showMenu(item)">
<el-icon>
<component :is="item.icon || 'el-icon-menu'" />
</el-icon>
<p>{{ vueAdmin.t(item.label) }}</p>
</li>
</ul>
</el-scrollbar>
</div>
</div>
</div>
<div v-if="!vueAdmin.state.isMobile && nextMenu.length > 0" class="aminui-side">
<div class="adminui-side-scroll">
<el-scrollbar>
<el-menu :default-active="$route.fullPath" router>
<navMenu :navMenus="nextMenu"></navMenu>
</el-menu>
</el-scrollbar>
</div>
</div>
<SideM v-if="vueAdmin.state.isMobile"></SideM>
<div class="aminui-body el-container">
<Topbar>
<userbar></userbar>
</Topbar>
<div class="adminui-main" id="adminui-main">
<router-view :key="$route.path" />
</div>
</div>
</section>
</template>
<script lang="ts" setup>
import SideM from "./sideM.vue";
import { ref, watch } from "vue";
import navMenu from "./navMenu.vue";
import Topbar from "./topbar.vue";
import Userbar from "./userbar.vue";
import { vueAdmin } from "@/VueAdmin";
import { type IMenuItem } from "@shrimp/IShrimpType";
import Logo from "./logo.vue";
// 初始化选中菜单
let currentPath = vueAdmin.router.currentRoute.value.path;
let pick = vueAdmin.getMenuIndex(currentPath);
let menu = pick >= 0 ? vueAdmin.menuTree[pick] : vueAdmin.menuTree[0];
let pmenu = ref(menu);
let nextMenu = ref(getMenu(vueAdmin.isGroupMenu(menu) ? menu.children : []));
function getMenu(children?: IMenuItem[]) {
if (children == null) return [];
return children.slice()
}
function getFirstLeafPath(item: IMenuItem): string {
if (item.path) return item.path
if (item.moduleKey) return '/' + item.moduleKey
if (item.children && item.children.length > 0) {
return getFirstLeafPath(item.children[0])
}
return '/' + item.name
}
function showMenu(route: any) {
pmenu.value = route;
nextMenu.value = getMenu(route.children);
if (route.children == null || route.children.length == 0) {
const path = route.path || '/' + route.name
vueAdmin.router.push(path)
} else {
// 展开菜单的同时,跳到第一个可跳转的叶子节点
const path = getFirstLeafPath(route.children[0])
vueAdmin.router.push(path)
}
}
// 路由变化时同步更新 pmenu 和 nextMenu
watch(() => vueAdmin.router.currentRoute.value.path, (path) => {
const idx = vueAdmin.getMenuIndex(path)
if (idx >= 0) {
const m = vueAdmin.menuTree[idx]
if (pmenu.value?.name !== m.name) {
pmenu.value = m
nextMenu.value = getMenu(vueAdmin.isGroupMenu(m) ? m.children : [])
}
}
})
</script>
<style scoped>
.sidebar-menu-wrap {
flex: 1;
overflow: hidden;
}
.sidebar-menu-wrap .el-scrollbar {
height: 100%;
}
</style>
@@ -1,130 +0,0 @@
<template>
<div class="login_bg">
<LangSwitch class="login-lang" />
<div class="login_main">
<div class="login-form">
<div class="login-header">
<div class="logo">
<label>{{ vueAdmin.adminConfig.title || 'Admin' }}</label>
</div>
</div>
<el-form ref="loginForm" :model="form" :rules="rules" label-width="0" size="large" @keyup.enter="login">
<el-form-item prop="user">
<el-input v-model="form.user" prefix-icon="el-icon-user" clearable
:placeholder="$t('login.userPlaceholder')">
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" prefix-icon="el-icon-lock" clearable show-password
:placeholder="$t('login.PWPlaceholder')"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" style="width: 100%" :loading="info.islogin" round @click="login">{{
$t("login.signIn") }}</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { vueAdmin } from "@/VueAdmin";
import { reactive, computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import LangSwitch from "@/components/LangSwitch.vue";
vueAdmin.token = null;
let { t } = useI18n();
let info = reactive({ islogin: false });
let form = reactive({ user: import.meta.env.DEV ? "admin" : "", password: import.meta.env.DEV ? "admin" : "" });
let rules = computed(() => ({
user: [{ required: true, message: t("login.userError"), trigger: "blur" }],
password: [{ required: true, message: t("login.PWError"), trigger: "blur" }],
}));
// 页面加载时获取后台配置
onMounted(() => {
vueAdmin.fetchConfig().then(data => {
Object.assign(vueAdmin.adminConfig, data)
}).catch(() => {})
})
function login() {
info.islogin = true;
vueAdmin.fetchLogin({ username: form.user, password: form.password })
.then(async (data) => {
vueAdmin.token = data.token;
await vueAdmin.loadUserInfo();
info.islogin = false;
vueAdmin.router.push(vueAdmin.getFirstMenuPath());
})
.catch((e: any) => {
console.warn('[Login]', e.message)
info.islogin = false;
});
}
</script>
<style scoped>
.login_bg {
width: 100%;
height: 100%;
background: #fff;
display: flex;
position: relative;
}
.login_main {
flex: 1;
overflow: auto;
display: flex;
}
.login-form {
width: 400px;
margin: auto;
padding: 20px 0;
}
.login-header {
margin-bottom: 40px;
}
.login-header .logo {
display: flex;
align-items: center;
justify-content: center;
}
.login-header .logo label {
font-size: 26px;
font-weight: bold;
}
.login-lang {
position: absolute;
top: 16px;
right: 16px;
}
@media (max-width: 1200px) {
.login-form {
width: 340px;
}
}
@media (max-width: 1000px) {
.login_main {
display: block;
}
.login-form {
width: 100%;
padding: 20px 40px;
}
}
</style>
@@ -1,17 +0,0 @@
<template>
<router-link :to="vueAdmin.getFirstMenuPath()">
<el-icon class="logo" size="20">
<el-icon-home-filled />
</el-icon>
</router-link>
</template>
<script setup lang="ts">
import { vueAdmin } from "../VueAdmin";
</script>
<style scoped>
.logo {
color: #fff;
}
</style>
@@ -1,37 +0,0 @@
<template>
<div v-if="navMenus.length <= 0" style="padding: 20px">
<el-alert :title="$t('base.noNavMenu')" center type="info" :closable="false"></el-alert>
</div>
<template v-for="navMenu in navMenus" v-bind:key="navMenu">
<el-menu-item v-if="!hasChildren(navMenu)" :index="'/' + navMenu.name">
<el-icon v-if="navMenu.icon">
<component :is="navMenu.icon || 'el-icon-menu'" />
</el-icon>
<template #title>
<span>{{ vueAdmin.t(navMenu.label) }}</span>
</template>
</el-menu-item>
<el-sub-menu v-else :index="'/' + navMenu.name">
<template #title>
<el-icon v-if="navMenu.icon">
<component :is="navMenu.icon || 'el-icon-menu'" />
</el-icon>
<span>{{ vueAdmin.t(navMenu.label) }}</span>
</template>
<NavMenu :navMenus="navMenu.children"></NavMenu>
</el-sub-menu>
</template>
</template>
<script lang="ts" setup>
import { vueAdmin } from "@/VueAdmin";
defineProps({
navMenus: {
type: [Object],
required: true,
},
});
function hasChildren(item: any) {
return item.children && item.children.length > 0;
}
</script>
@@ -1,83 +0,0 @@
<template>
<div class="mobile-nav-button" @click="showMobileNav">
<el-icon><el-icon-menu /></el-icon>
</div>
<el-drawer
:size="240"
v-model="nav"
direction="ltr"
:with-header="false"
destroy-on-close
>
<el-container class="mobile-nav">
<el-header>
<logo/>
</el-header>
<el-main>
<el-scrollbar>
<el-menu
:default-active="$route.fullPath"
@select="select"
router
background-color="#212d3d"
text-color="#fff"
active-text-color="#409EFF"
>
<navMenu :navMenus="vueAdmin.menuTree"></navMenu>
</el-menu>
</el-scrollbar>
</el-main>
</el-container>
</el-drawer>
</template>
<script lang="ts" setup>
import navMenu from "./navMenu.vue";
import Logo from "./logo.vue";
import { ref } from "vue";
import { vueAdmin } from '@/VueAdmin';
let nav = ref(false);
function showMobileNav() { nav.value = true; }
function select() {}
</script>
<style scoped>
.mobile-nav-button {
position: fixed;
bottom: 10px;
left: 10px;
z-index: 10;
width: 50px;
height: 50px;
background: #409eff;
box-shadow: 0 2px 12px 0 rgba(64, 158, 255, 1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.mobile-nav-button i {
color: #fff;
font-size: 20px;
}
.mobile-nav {
background: #212d3d;
}
.mobile-nav .el-header {
background: transparent;
border: 0;
}
.mobile-nav .el-main {
padding: 0;
}
.mobile-nav .logo-bar {
display: flex;
align-items: center;
font-weight: bold;
font-size: 20px;
color: #fff;
}
</style>
@@ -1,102 +0,0 @@
<template>
<div class="adminui-topbar">
<div class="left-panel">
<el-button v-if="canBack" :icon="ArrowLeft" link size="large" class="back-btn" @click="handleBack">{{ $t('base.back') }}</el-button>
<el-breadcrumb separator-icon="el-icon-arrow-right">
<template v-for="(item, index) in breadcrumb" :key="index">
<el-breadcrumb-item v-if="item.to && index < breadcrumb.length - 1" :to="item.to">
{{ vueAdmin.t(item.label) }}
</el-breadcrumb-item>
<el-breadcrumb-item v-else>{{ vueAdmin.t(item.label) }}</el-breadcrumb-item>
</template>
</el-breadcrumb>
</div>
<div class="center-panel"></div>
<div class="right-panel">
<slot></slot>
</div>
</div>
</template>
<script lang="ts" setup>
import { vueAdmin } from "@/VueAdmin";
import { computed } from "vue";
import { ArrowLeft } from "@element-plus/icons-vue";
import type { RouteLocationRaw } from "vue-router";
interface BreadcrumbItem {
key: string;
label: any;
to?: RouteLocationRaw;
}
// 把 ?id + ?pids 复原成 root→immediate-parent 的 id 数组
function getAncestorIds(): string[] {
const route = vueAdmin.router.currentRoute.value;
const pids = ((route.query.pids as string) || '').split(',').filter(Boolean);
const id = route.query.id as string | undefined;
return id ? [...pids, id] : pids;
}
const breadcrumb = computed<BreadcrumbItem[]>(() => {
const route = vueAdmin.router.currentRoute.value;
const raw = vueAdmin.getBreadcrumb(route.path);
const ancestors = getAncestorIds();
return raw.map(item => {
if (!item.path) return { key: item.key, label: item.label };
// path 段数 - 1 = 模块链深度 (0=顶层, >=1=子模块)
const depth = item.path.split('/').filter(Boolean).length - 1;
if (depth <= 0) return { key: item.key, label: item.label, to: item.path };
// 该 depth 的页面需要 ?id = ancestors[depth-1], pids = ancestors[0..depth-2]
const id = ancestors[depth - 1];
if (!id) return { key: item.key, label: item.label, to: item.path };
const newPids = ancestors.slice(0, depth - 1);
const query: Record<string, string> = { id };
if (newPids.length > 0) query.pids = newPids.join(',');
return { key: item.key, label: item.label, to: { path: item.path, query } };
});
});
// 倒数第二个可点的 item 即"上一级"
const backTarget = computed<RouteLocationRaw | undefined>(() => {
const list = breadcrumb.value;
for (let i = list.length - 2; i >= 0; i--) {
if (list[i].to) return list[i].to;
}
return undefined;
});
const canBack = computed(() => !!backTarget.value);
function handleBack() {
// 优先用浏览器历史(保留来时的完整 query);无历史则按 breadcrumb 计算的目标 push
if (window.history.length > 1) {
vueAdmin.router.back();
} else if (backTarget.value) {
vueAdmin.router.push(backTarget.value);
}
}
</script>
<style scoped>
.left-panel {
display: flex;
align-items: center;
}
.back-btn {
margin-left: 10px;
margin-right: 16px;
}
.el-breadcrumb {
margin-left: 8px;
}
/* 小屏: 只保留最后一段(当前页);返回按钮和菜单覆盖上层导航 */
@media (max-width: 768px) {
.el-breadcrumb :deep(.el-breadcrumb__item):not(:last-child) {
display: none;
}
}
</style>
@@ -1,90 +0,0 @@
<template>
<div class="user-bar">
<LangSwitch class="panel-item" />
<div class="panel-item" @click="screen">
<el-icon><el-icon-full-screen /></el-icon>
</div>
<el-dropdown class="user" trigger="click" @command="handleUser">
<div class="user-avatar dropdown-panel-item">
<label>{{ vueAdmin.userName || 'Admin' }}</label>
<el-icon class="el-icon--right"><el-icon-arrow-down /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="clearCache">{{ $t("base.clearCache") }}</el-dropdown-item>
<el-dropdown-item divided command="outLogin">{{ $t("base.logout") }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script lang="ts" setup>
import { vueAdmin } from "@/VueAdmin";
import LangSwitch from "@/components/LangSwitch.vue";
function screen() {
vueAdmin.fullscreen();
}
function handleUser(command: string) {
switch (command) {
case "clearCache":
vueAdmin.clearCache();
vueAdmin.reset();
break;
case "outLogin":
vueAdmin.reset();
break;
}
}
</script>
<style scoped>
.user-bar {
display: flex;
align-items: center;
height: 100%;
}
.user-bar .panel-item {
padding: 0 10px;
cursor: pointer;
height: 100%;
display: flex;
align-items: center;
}
.user-bar .panel-item i {
font-size: 16px;
}
.user-bar .panel-item:hover {
background: rgba(0, 0, 0, 0.1);
}
.dropdown-panel-item {
padding: 0 10px;
cursor: pointer;
height: 100%;
display: flex;
align-items: center;
}
.dropdown-panel-item:hover {
background: rgba(0, 0, 0, 0.1);
}
.user-bar .user-avatar {
height: 49px;
display: flex;
align-items: center;
}
.user-bar .user-avatar label {
display: inline-block;
margin-left: 5px;
font-size: 12px;
cursor: pointer;
}
</style>
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-41
View File
@@ -1,41 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"noLib": false,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"strictFunctionTypes": false,
"jsx": "preserve",
"baseUrl": ".",
"allowJs": true,
"sourceMap": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"experimentalDecorators": true,
"lib": ["dom", "esnext"],
"noImplicitAny": false,
"skipLibCheck": true,
"types": ["vite/client"],
"removeComments": true,
"paths": {
"@/*": ["src/*"],
"@shrimp/*": ["../../../src/*"],
"#/*": ["types/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue",
"types/**/*.d.ts",
"types/**/*.ts",
"vite.config.ts"
],
"exclude": ["node_modules", "dist", "**/*.js"]
}
@@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
-36
View File
@@ -1,36 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import * as path from "path";
// shrimp-server/examples/basic-admin/web/vite.config.ts
// 这是 examples 临时演示前端,@shrimp 直接指向同仓 src/
export default defineConfig({
base: '/',
resolve: {
alias:
[
{
find: '@',
replacement: path.resolve(__dirname, './src')
},
{
find: '@shrimp',
replacement: path.resolve(__dirname, '../../../src')
},
]
},
plugins: [vue({ reactivityTransform: true })],
server: {
port: 5173,
proxy: {
"/admin-api": {
target: "http://localhost:3100",
changeOrigin: true,
},
"/api": {
target: "http://localhost:3100",
changeOrigin: true,
},
}
}
})
+1 -2
View File
@@ -23,8 +23,7 @@
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"demo": "tsx examples/basic-admin/index.ts"
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"express": "^4 || ^5",
+2 -2
View File
@@ -15,6 +15,6 @@
"rootDir": "./",
"types": ["node"]
},
"include": ["src/**/*", "test/**/*", "examples/**/*"],
"exclude": ["node_modules", "dist", "examples/**/web/**"]
"include": ["src/**/*", "test/**/*"],
"exclude": ["node_modules", "dist"]
}