v0.3.0: 单 sources 入口 + 路径后缀覆盖约定

破坏性重构。框架对外只暴露一个 `sources` glob 入口,消费侧任何 .vue
都按文件路径后缀匹配同后缀覆盖:

- shrimp/components/<name>.vue 同后缀覆盖框架内置(login/layout/topbar/ShrimpTable/...)
- shrimp/components/fields/Field<X>.vue 注册类型 <x>(首字母小写)
- shrimp/module/<key>.vue 业务模块

变更:
- 删除 customFields / moduleSources / overrideField / registerField 旧入口
- 删除 fieldOverrides Map,字段表纯靠 glob 扫描注册
- 框架内 .vue 对可覆盖 peer 改用 defineAsyncComponent(resolveVue(...))
  (login → ShrimpLangSwitch / layout → sideM/navMenu/topbar/userbar/logo /
   ShrimpTable → ShrimpEditDrawer/ShrimpFilterDrawer/...)
- views/ 全部并入 components/,App.vue 上提到 src/ 根
- LangSwitch → ShrimpLangSwitch,PowerTree → ShrimpPowerTree
- module/FieldRelativeTime.vue → components/fields/FieldRelativeTime.vue
- module/user.vue 改用 #column-updatedAt slot 替代旧的 overrideField

字段类型约定从 'field' + Name 改为 lowercase-first(FieldString → 'string'),
内置和消费侧统一。
This commit is contained in:
2026-05-25 15:05:16 +08:00
parent ac57de97cb
commit 2b33bca681
20 changed files with 165 additions and 112 deletions
+10
View File
@@ -0,0 +1,10 @@
<template>
<div>
<h1>404</h1>
</div>
</template>
<script lang="ts" setup>
</script>
<style scoped></style>
+7 -6
View File
@@ -78,21 +78,22 @@
</template>
<script lang="ts" setup>
import { ref, computed, useSlots, onMounted, type Component } from 'vue'
import { defineAsyncComponent, ref, computed, useSlots, onMounted, type Component } from 'vue'
import { ITableCrudProto, IFieldConfig, IModuleInfo, IPageQuery } from '../IShrimpType'
import { vueAdmin } from '../VueAdmin'
import { vueAdmin, resolveVue } 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 ShrimpEditDrawer = defineAsyncComponent(resolveVue('components/ShrimpEditDrawer.vue'))
const ShrimpFilterDrawer = defineAsyncComponent(resolveVue('components/ShrimpFilterDrawer.vue'))
const ShrimpSearchInput = defineAsyncComponent(resolveVue('components/ShrimpSearchInput.vue'))
const ShrimpFilterTrigger = defineAsyncComponent(resolveVue('components/ShrimpFilterTrigger.vue'))
const { t } = useI18n()
interface Props {
@@ -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>
+111
View File
@@ -0,0 +1,111 @@
<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 { defineAsyncComponent, ref, watch } from "vue";
import { vueAdmin, resolveVue } from "../VueAdmin";
import { type IMenuItem } from "../IShrimpType";
const SideM = defineAsyncComponent(resolveVue('components/sideM.vue'));
const navMenu = defineAsyncComponent(resolveVue('components/navMenu.vue'));
const Topbar = defineAsyncComponent(resolveVue('components/topbar.vue'));
const Userbar = defineAsyncComponent(resolveVue('components/userbar.vue'));
const Logo = defineAsyncComponent(resolveVue('components/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>
+131
View File
@@ -0,0 +1,131 @@
<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, resolveVue } from "../VueAdmin";
import { defineAsyncComponent, reactive, computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
const LangSwitch = defineAsyncComponent(resolveVue('components/ShrimpLangSwitch.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>
+84
View File
@@ -0,0 +1,84 @@
<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 { defineAsyncComponent, ref } from "vue";
import { vueAdmin, resolveVue } from '../VueAdmin';
const navMenu = defineAsyncComponent(resolveVue('components/navMenu.vue'));
const Logo = defineAsyncComponent(resolveVue('components/logo.vue'));
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>
+92
View File
@@ -0,0 +1,92 @@
<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, resolveVue } from "../VueAdmin";
import { defineAsyncComponent } from "vue";
const LangSwitch = defineAsyncComponent(resolveVue('components/ShrimpLangSwitch.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>