feat: shrimp-admin v0.1.0 — 从 hand-admin 抽离的框架前端

Shrimp 框架前端 — 一种消费 ShrimpServer + ShrimpTable 契约的经典 Vue3 +
Element Plus 实现。不是框架本身,只是按 shrimp-server 约定写的一份参考解法。

包含:
- ShrimpClient + VueAdmin(factory:createShrimpAdmin)
- 5 个 Shrimp* 组件 + 10 个内置 Field*.vue + PowerTree + LangSwitch
- 4 个内置模块:user / role / operationLog / FieldRelativeTime
- 完整后台壳 views(layout/login/sideM/topbar/userbar/navMenu/App/404/logo)
- composables/useTableUrlSync, locales(zh-cn/en 框架默认), style 全套

API 改造:
- createShrimpAdmin({ config, moduleSources, customFields, locales }) factory
- vueAdmin 改为 Proxy 单例,createShrimpAdmin 调用前访问会抛错
- import.meta.glob 业务字段/模块由消费侧传入(vite 静态扫描机制限制)
- 框架默认 locales 与消费侧 locales 在 i18n 初始化时合并

源码 ship 不打包,消费侧靠 vite 直接吃 .ts/.vue。
This commit is contained in:
2026-05-23 21:01:33 +08:00
parent b4ee2e4b43
commit 4cb3bb8d75
50 changed files with 6551 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
node_modules/
+46
View File
@@ -1,2 +1,48 @@
# shrimp-admin
Shrimp 框架前端 —— 一种**经典解决方案**(不是框架本身),消费 [shrimp-server](http://git.zeromvc.com/wc24/shrimp-server) 暴露的 `ShrimpServer` 路由契约 + `ShrimpTable` 数据形状契约,基于 **Vue 3 + Element Plus** 实现一套可直接用的后台壳。
如果你想换 UI 库或者换前端技术栈,直接照 shrimp-server 的约定再写一份就行,不依赖本包。
## 安装
```bash
# 推荐 HTTP
npm i git+http://git.zeromvc.com/wc24/shrimp-admin.git#v0.1.0
```
shrimp-admin 是**源码 ship**(.ts + .vue 直接发),由消费侧 Vite 编译。
## 使用
```ts
// main.ts
import { createApp } from 'vue'
import { createShrimpAdmin, App } from 'shrimp-admin'
import 'shrimp-admin/style'
const app = createApp(App)
app.use(createShrimpAdmin({
config: {
API_URL: '/admin-api',
TIMEOUT: 10000,
},
moduleSources: import.meta.glob('./module/**/*.vue'),
customFields: import.meta.glob('./module/fields/Field*.vue'),
}))
app.mount('#app')
```
## 消费的契约
参见 shrimp-server README `## 约定` 章节。本包消费以下条件契约:
- **ShrimpTable**: list 响应 `{list,total,page,pageSize,totalPages}` / 字段 type 枚举 / populate `${key}_label`
- **ShrimpServer**: HTTP 路由 / JWT Bearer / IUserAccessor 返回的 powers
- **默认 Field*.vue**: 字段 type 必须是内置枚举之一,或通过 `customFields` 注册
## 扩展点
- `customFields` — 业务字段组件(`Field<Name>.vue`)
- `moduleSources` — 业务模块组件(`module/<key>.vue` / `module/<parent>/<sub>.vue`)
- `vueAdmin.registerField(type, comp)` / `overrideField(moduleKey, fieldKey, comp)` — 运行时也可
+1542
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "shrimp-admin",
"version": "0.1.0",
"private": true,
"description": "Shrimp 框架前端 — 消费 ShrimpServer + ShrimpTable 契约的经典 Vue3 + Element Plus 实现",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./style": "./src/style/style.scss",
"./package.json": "./package.json"
},
"files": [
"src",
"README.md"
],
"dependencies": {
"shrimp-server": "git+http://git.zeromvc.com/wc24/shrimp-server.git#v0.5.0"
},
"peerDependencies": {
"vue": "^3.3.0",
"vue-router": "^4.0.0",
"vue-i18n": "^9.0.0",
"axios": "^1.0.0",
"element-plus": "^2.0.0",
"@element-plus/icons-vue": "^2.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.3.0",
"vite": "^4.4.5",
"vue": "^3.3.4",
"vue-router": "^4.1.6",
"vue-i18n": "^9.1.9",
"axios": "^1.3.4",
"element-plus": "^2.10.4",
"@element-plus/icons-vue": "^2.1.0"
}
}
+372
View File
@@ -0,0 +1,372 @@
/**
* ShrimpClient — Shrimp 框架前端基类
* 框架无关层,包含协议通信、模块管理、菜单树操作
*/
import { ShrimpCode, ShrimpRequestFn, IShrimpResponse, IModuleInfo, IMenuItem, ILoginResult, IMeResult, IShrimpClient, ILabel } from 'shrimp-server/types'
/** 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()
}
}
}
+395
View File
@@ -0,0 +1,395 @@
import * as elIcons from '@element-plus/icons-vue'
import { type App, type Component, type Plugin, 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 { IShrimpResponse, ILabel } from 'shrimp-server/types'
import { ShrimpClient, handleShrimpResponse, type IShrimpTipper, type IRouteInfo } from './ShrimpClient'
import FieldString from './components/fields/FieldString.vue'
// ============== Options ==============
export interface ShrimpAdminConfig {
API_URL: string
TIMEOUT?: number
HEADERS?: Record<string, string>
APP_VER?: string
LS_ENCRYPTION?: string
LS_ENCRYPTION_key?: string
}
export interface ShrimpAdminOptions {
config: ShrimpAdminConfig
/** 业务模块组件:`import.meta.glob('./module/**\/*.vue')` 的结果 */
moduleSources?: Record<string, () => Promise<{ default: Component } | Component>>
/** 业务字段组件:`import.meta.glob('./module/fields/Field*.vue')`,文件名 FieldXxx → type='fieldXxx' */
customFields?: Record<string, () => Promise<{ default: Component } | Component>>
/** 语言资源,与框架自带的合并(消费侧同 key 覆盖框架默认) */
locales?: Record<string, any>
/** 语言列表,默认 [{ lang: 'zh-cn', name: '简体中文' }] */
langList?: { lang: string; name: string }[]
}
// ============== 字段组件注册表 ==============
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')))
// 框架自带模块组件 — vite glob 相对本文件解析,扫到的是 shrimp-admin 内部的 module/
const internalModules = import.meta.glob<Component>('./module/**/*.vue')
// 业务模块由消费侧通过 createShrimpAdmin({ moduleSources }) 注册
const externalModules: Record<string, () => Promise<any>> = {}
function resolveModuleComponent(componentPath: string): (() => Promise<any>) | null {
const suffix = `/module/${componentPath}.vue`
// 业务优先(允许覆盖同名内置)
for (const key in externalModules) {
if (key.endsWith(suffix)) return externalModules[key]
}
for (const key in internalModules) {
if (key.endsWith(suffix)) return internalModules[key] as any
}
return null
}
// ============== LocalData ==============
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 ==============
export class VueAdmin extends ShrimpClient {
router!: Router
localData = new LocalData()
i18n!: I18n
adminConfig: Record<string, any> = reactive({})
userName = ''
config: ShrimpAdminConfig
langList: { lang: string; name: string }[]
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 })
private _token: string | null = null
get token() { return this._token }
set token(value: string | null) {
this.localData.set("token", value)
this._token = value
}
get langCount() { return this.langList.length }
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
}
request: ReturnType<typeof axios.create>
constructor(opts: ShrimpAdminOptions) {
super()
this.config = opts.config
this.langList = opts.langList || [{ lang: 'zh-cn', name: '简体中文' }]
// 业务模块/字段注册
if (opts.moduleSources) Object.assign(externalModules, opts.moduleSources)
if (opts.customFields) {
for (const path in opts.customFields) {
const match = path.match(/\/Field(.+)\.vue$/)
if (match) {
const name = 'field' + match[1]
fieldRegistry.set(name, defineAsyncComponent(opts.customFields[path] as any))
}
}
}
this.request = axios.create({
baseURL: this.config.API_URL,
timeout: this.config.TIMEOUT ?? 10000,
})
this.initHttp()
this.initI18n(opts.locales)
this.initRouter()
this.initMobileCheck()
this._token = this.localData.get("token")
}
initI18n(extraLocales?: Record<string, any>) {
const merged: Record<string, any> = {
'zh-cn': { ...zh_cn, ...(extraLocales?.['zh-cn'] || {}) },
'en': { ...en, ...(extraLocales?.['en'] || {}) },
}
if (extraLocales) {
for (const key in extraLocales) {
if (!(key in merged)) merged[key] = extraLocales[key]
}
}
this.i18n = createI18n({
locale: this.localData.get("lang") || 'zh-cn',
globalInjection: true,
legacy: false,
messages: merged,
})
}
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
}
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
}
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
}
registerRoute(info: IRouteInfo) {
const componentPath = info.componentPath || info.moduleKey
const importer = resolveModuleComponent(componentPath)
if (!importer) {
console.warn(`[shrimp-admin] 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 }
})
}
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 {
this.token = null
next({ path: '/login' })
}
} else {
next()
}
})
}
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,
}),
}
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
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()
}
}
// ============== 单例 + factory ==============
let _instance: VueAdmin | null = null
export const vueAdmin = new Proxy({} as VueAdmin, {
get(_t, prop) {
if (!_instance) throw new Error('[shrimp-admin] vueAdmin accessed before createShrimpAdmin() was called')
return Reflect.get(_instance, prop, _instance)
},
set(_t, prop, value) {
if (!_instance) throw new Error('[shrimp-admin] vueAdmin accessed before createShrimpAdmin() was called')
return Reflect.set(_instance, prop, value, _instance)
}
})
export function createShrimpAdmin(opts: ShrimpAdminOptions): Plugin {
if (_instance) {
console.warn('[shrimp-admin] createShrimpAdmin called more than once; reusing existing instance')
return shrimpAdminPlugin
}
_instance = new VueAdmin(opts)
return shrimpAdminPlugin
}
const shrimpAdminPlugin: Plugin = {
install(app: App) {
if (!_instance) throw new Error('[shrimp-admin] install called before createShrimpAdmin()')
app.use(ElementPlus)
app.use(_instance.i18n)
app.use(_instance.router)
for (const key in elIcons) {
app.component(`ElIcon${key}`, (elIcons as any)[key])
}
}
}
+21
View File
@@ -0,0 +1,21 @@
<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>
+47
View File
@@ -0,0 +1,47 @@
<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>
+122
View File
@@ -0,0 +1,122 @@
<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-server/types'
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>
+126
View File
@@ -0,0 +1,126 @@
<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-server/types'
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>
+104
View File
@@ -0,0 +1,104 @@
<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-server/types'
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>
+22
View File
@@ -0,0 +1,22 @@
<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>
+88
View File
@@ -0,0 +1,88 @@
<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>
+583
View File
@@ -0,0 +1,583 @@
<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-server/types'
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>
+36
View File
@@ -0,0 +1,36 @@
<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>
+137
View File
@@ -0,0 +1,137 @@
<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>
+48
View File
@@ -0,0 +1,48 @@
<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>
+82
View File
@@ -0,0 +1,82 @@
<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>
+20
View File
@@ -0,0 +1,20 @@
<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>
+94
View File
@@ -0,0 +1,94 @@
<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-server/types'
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>
+65
View File
@@ -0,0 +1,65 @@
<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>
+59
View File
@@ -0,0 +1,59 @@
<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>
+120
View File
@@ -0,0 +1,120 @@
<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>
+78
View File
@@ -0,0 +1,78 @@
<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>
+98
View File
@@ -0,0 +1,98 @@
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,
}
}
+30
View File
@@ -0,0 +1,30 @@
// ============== 主入口 ==============
//
// 消费侧用法见 README.md。本文件只做 re-export。
export {
VueAdmin,
vueAdmin,
createShrimpAdmin,
type ShrimpAdminOptions,
type ShrimpAdminConfig,
} from './VueAdmin'
export {
ShrimpClient,
handleShrimpResponse,
type IShrimpTipper,
type IRouteInfo,
} from './ShrimpClient'
// 框架组件 — 业务页面想直接复用就 import 这些
export { default as ShrimpTable } from './components/ShrimpTable.vue'
export { default as ShrimpEditDrawer } from './components/ShrimpEditDrawer.vue'
export { default as ShrimpFilterDrawer } from './components/ShrimpFilterDrawer.vue'
export { default as ShrimpFilterTrigger } from './components/ShrimpFilterTrigger.vue'
export { default as ShrimpSearchInput } from './components/ShrimpSearchInput.vue'
export { default as PowerTree } from './components/PowerTree.vue'
export { default as LangSwitch } from './components/LangSwitch.vue'
// 应用入口视图(消费侧 createApp 用)
export { default as App } from './views/App.vue'
+132
View File
@@ -0,0 +1,132 @@
{
"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",
"zerobase": "Zero Base",
"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"
}
}
+130
View File
@@ -0,0 +1,130 @@
{
"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格式错误"
}
}
+42
View File
@@ -0,0 +1,42 @@
<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>
+238
View File
@@ -0,0 +1,238 @@
<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-server/types'
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>
+14
View File
@@ -0,0 +1,14 @@
<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>
+67
View File
@@ -0,0 +1,67 @@
<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>
+5
View File
@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+521
View File
@@ -0,0 +1,521 @@
/* 全局 */
#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;
}
+42
View File
@@ -0,0 +1,42 @@
@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;}
}
+91
View File
@@ -0,0 +1,91 @@
/* 覆盖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;
}
+174
View File
@@ -0,0 +1,174 @@
@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;
}
}
+44
View File
@@ -0,0 +1,44 @@
/* 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);}
+6
View File
@@ -0,0 +1,6 @@
@use 'app.scss';
@use 'fix.scss';
@use 'pages.scss';
@use 'media.scss';
@use 'dark.scss';
@use 'zd.scss';
+48
View File
@@ -0,0 +1,48 @@
.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;
}
+10
View File
@@ -0,0 +1,10 @@
<template>
<div>
<h1>404</h1>
</div>
</template>
<script lang="ts" setup>
</script>
<style scoped></style>
+24
View File
@@ -0,0 +1,24 @@
<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>
+110
View File
@@ -0,0 +1,110 @@
<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-server/types";
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>
+130
View File
@@ -0,0 +1,130 @@
<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>
+17
View File
@@ -0,0 +1,17 @@
<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>
+37
View File
@@ -0,0 +1,37 @@
<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>
+83
View File
@@ -0,0 +1,83 @@
<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>
+102
View File
@@ -0,0 +1,102 @@
<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>
+90
View File
@@ -0,0 +1,90 @@
<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>
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"esModuleInterop": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"lib": ["dom", "esnext"],
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"]
}