feat(company): 公司列表编辑改抽屉 + 公司详情展示已启用应用

CrudFormPanel 支持 mode=drawer(CrudTable 新增 formMode prop 透传,默认仍是居中弹窗,避免表格嵌在弹窗里的场景叠抽屉);Company 列表页启用 form-mode=drawer。

公司详情页新增「已启用应用」卡片(仅管理员可见):列出该公司 InstallStatus=1 的应用,支持卸载(停用本公司安装,不动租户级/其它公司)与配置(跳 /app/config 并带 CompanyUUID 定位本公司范围)。新增 appApi.companyApps;AppConfigPage 支持 CompanyUUID 查询参数预选范围。
This commit is contained in:
TermiNexus committed 2026-09-29 18:50:16 +08:00
1 parent 2d989d5af1
commit bb25b6f4d6
6 files changed
+213 -4

No files matched your search

+7
View File
@@ -146,6 +146,13 @@ export const appApi = {
AppUUID ? { AppUUID } : {},
),
/**
* 指定公司已启用的应用列表(公司详情页用);
* 权限:管理员 + 该公司在可管理范围内(super=全部 / tenant=本租户 / company=被授权公司)
*/
companyApps: (CompanyUUID: string): Promise<{ list: AppItem[]; total: number }> =>
get<{ list: AppItem[]; total: number }>('/API/App/CompanyApps', { CompanyUUID }),
/** 管理员:安装企业功能(不传 CompanyUUIDs=全租户;传 CompanyUUIDs=指定公司);
* GrantRoles=RAM 角色粒度授权(可选):指定角色可见该应用(安装范围内) */
install: (AppUUID: string, CompanyUUIDs?: string[], GrantRoles?: string[]): Promise<AppItem> =>
+15 -3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElDialog, ElDrawer } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import type { ColumnSchema } from '@/types/api'
import EntityPicker from './EntityPicker.vue'
@@ -25,6 +26,12 @@ const props = defineProps<{
/** 表单数据对象(引用:直接改属性,父组件 editingRow) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 对齐 useCrudTable 的 editingRow 类型
formModel: Record<string, any>
/**
* 表单容器形态:
* - 'dialog'(默认):居中弹窗;适用于 CrudTable 自身嵌在弹窗里的场景(弹窗内不宜再开抽屉)
* - 'drawer':右侧抽屉;列表页"编辑"用(由 CrudTable 的 formMode 透传)
*/
mode?: 'dialog' | 'drawer'
}>()
const emit = defineEmits<{
@@ -42,6 +49,9 @@ const emit = defineEmits<{
const formRef = ref<FormInstance>()
/** 是否抽屉形态(列表页编辑);默认居中弹窗(表格嵌在弹窗内的场景不适用抽屉) */
const isDrawer = computed(() => props.mode === 'drawer')
// required 列自动生成校验规则(required 为样式标记 → 真实校验)
const rules = computed<FormRules>(() => {
const r: FormRules = {}
@@ -129,10 +139,12 @@ watch(
</script>
<template>
<el-dialog
<!-- 容器:dialog(默认,适用于表格内嵌弹窗场景)/ drawer(列表页编辑抽屉) -->
<component
:is="isDrawer ? ElDrawer : ElDialog"
:model-value="visible"
:title="title"
width="600px"
v-bind="isDrawer ? { direction: 'rtl', size: 600 } : { width: '600px' }"
destroy-on-close
@update:model-value="(v: boolean) => emit('update:visible', v)"
>
@@ -290,5 +302,5 @@ watch(
<el-button @click="emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button>
</template>
</el-dialog>
</component>
</template>
+6
View File
@@ -86,6 +86,12 @@ const props = withDefaults(defineProps<{
* (用于 role_scope 非管理员场景,如 Products/Orders 仅管理员可改)。
*/
editable?: boolean
/**
* 新增/编辑表单容器形态(默认 'dialog' 居中弹窗):
* 传 'drawer' 时改为右侧抽屉(列表页编辑建议用抽屉)。
* 注意:表格自身嵌在弹窗里时不要用抽屉(弹窗内再叠抽屉会错位)。
*/
formMode?: 'dialog' | 'drawer'
}>(), {
// editable 必须显式给默认值 true:Vue 会把「未传的 Boolean prop」强制转成 false,
// 不给默认值时 props.editable 恒为 false,canEdit 判定失败 → 所有未传 editable 的
+8
View File
@@ -19,6 +19,8 @@ import { setPageTitle } from '@/utils/serverScope'
const route = useRoute()
const AppUUID = computed(() => (route.query.AppUUID as string) || '')
const AppName = computed(() => (route.query.AppName as string) || '')
/** URL 指定公司(公司详情页「已启用应用 → 配置」入口):优先定位到该公司范围 */
const QueryCompanyUUID = computed(() => (route.query.CompanyUUID as string) || '')
setPageTitle('应用配置')
@@ -61,6 +63,12 @@ async function initScope(): Promise<void> {
const r = await appApi.manageCompanies()
companies.value = r.Companies || []
scope.value = r.Scope || ''
// URL 指定公司(公司详情页进入):在可管理范围内则直接定位到该公司
const target = QueryCompanyUUID.value
if (target && companies.value.some((c) => c.CompanyUUID === target)) {
currentCompany.value = target
return
}
if (scope.value === 'company') {
// 公司管理员:默认本公司(第一个可管理公司)
currentCompany.value = companies.value[0]?.CompanyUUID || ''
+1
View File
@@ -99,6 +99,7 @@ async function handleIconChange(event: Event): Promise<void> {
searchable
stretch
selectable
form-mode="drawer"
card-mode
card-title-field="CompanyName"
card-icon-field="CompanyIcon"
+176 -1
View File
@@ -24,12 +24,15 @@
*/
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Grid } from '@element-plus/icons-vue'
import DetailPage from '@/components/DetailPage.vue'
import CrudTable from '@/components/CrudTable.vue'
import EntityPicker from '@/components/EntityPicker.vue'
import BatchPositionDialog from '@/components/BatchPositionDialog.vue'
import { formApi } from '@/api/form'
import { appApi, type AppItem } from '@/api/app'
import { useUserStore } from '@/stores/user'
import { upload } from '@/api/request'
import { useResolveUUIDs } from '@/composables/useResolveUUIDs'
import { setPageTitle } from '@/utils/serverScope'
@@ -53,6 +56,7 @@ type RowData = Record<string, unknown>
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const { resolveUUIDs } = useResolveUUIDs()
/** 加载态 */
@@ -250,6 +254,59 @@ async function saveEdit(): Promise<void> {
}
}
// ===== 已启用应用(公司维度:安装记录 InstallStatus=1)=====
/**
* 仅管理员可见(后端 /API/App/CompanyApps 按可管理范围校验:
* super=全部 / tenant=本租户 / company=被授权公司),非管理员不发请求。
*/
const companyApps = ref<AppItem[]>([])
const appsLoading = ref(false)
async function loadCompanyApps(): Promise<void> {
if (!userStore.isAdmin || !companyUUID.value) {
companyApps.value = []
return
}
appsLoading.value = true
try {
const r = await appApi.companyApps(companyUUID.value)
companyApps.value = r.list || []
} catch {
// 错误提示已由响应拦截器统一弹出(无权/网络异常时按空列表展示)
companyApps.value = []
} finally {
appsLoading.value = false
}
}
/** 打开应用配置页:带上 CompanyUUID 让配置页直接定位到本公司范围 */
function openAppConfig(a: AppItem): void {
router.push({
path: '/app/config',
query: { AppUUID: a.AppUUID, AppName: a.AppName, CompanyUUID: companyUUID.value },
})
}
/** 卸载:停用本公司的安装记录(租户级安装与其它公司安装不受影响) */
async function uninstallApp(a: AppItem): Promise<void> {
try {
await ElMessageBox.confirm(
`确认在本公司停用「${a.AppName}」?停用后本公司用户将不可使用该应用(租户级安装与其它公司不受影响)。`,
'确认停用',
{ type: 'warning', confirmButtonText: '停用', cancelButtonText: '取消' },
)
} catch {
return // 用户取消
}
try {
await appApi.uninstall(a.AppUUID, [companyUUID.value])
ElMessage.success('已停用')
await loadCompanyApps()
} catch {
// 错误提示已由响应拦截器统一弹出
}
}
onMounted(loadCompany)
// 同一 tab 复用(v-show 常驻)时 query 变化:重新加载(防展示旧公司数据 / 缺参报错)
@@ -257,6 +314,7 @@ onMounted(loadCompany)
// 空值场景静默显示空态(loadCompany 内处理),不再弹警告
watch(companyUUID, () => {
void loadCompany()
void loadCompanyApps()
}, { immediate: true })
</script>
@@ -326,6 +384,45 @@ watch(companyUUID, () => {
</el-descriptions>
</el-card>
<!-- 已启用应用(公司维度的安装记录):仅管理员可见,可卸载/配置 -->
<el-card v-if="userStore.isAdmin" shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Grid /></el-icon>
<span>已启用应用</span>
<el-tag v-if="companyApps.length" size="small" type="success" effect="plain">
{{ companyApps.length }}
</el-tag>
<el-button class="card-header-action" link type="primary" size="small" @click="router.push('/app/market')">
去应用市场
</el-button>
</div>
</template>
<div v-loading="appsLoading" class="company-apps">
<el-empty
v-if="!appsLoading && !companyApps.length"
description="该公司暂无已启用应用,可到应用市场安装"
:image-size="70"
/>
<template v-else>
<div v-for="a in companyApps" :key="a.AppUUID" class="company-app-item">
<div class="company-app-icon">
<img v-if="a.AppIcon" :src="a.AppIcon" :alt="a.AppName" />
<el-icon v-else :size="20"><Grid /></el-icon>
</div>
<div class="company-app-main">
<div class="company-app-name">{{ a.AppName || a.AppCode }}</div>
<div class="company-app-desc">{{ a.AppDesc || a.AppCode }}</div>
</div>
<div class="company-app-actions">
<el-button size="small" type="info" plain @click="openAppConfig(a)">配置</el-button>
<el-button size="small" type="danger" plain @click="uninstallApp(a)">卸载</el-button>
</div>
</div>
</template>
</div>
</el-card>
<!-- 子表 1:部门 -->
<el-card shadow="never" class="detail-card">
<template #header>
@@ -642,6 +739,84 @@ watch(companyUUID, () => {
// ===== 子表卡片 =====
// 使用全局 .detail-card / .detail-card-header(见 global.scss),此处无需重复定义
// ===== 已启用应用(公司维度) =====
// 卡片头部右侧动作按钮(对齐右侧)
.card-header-action {
margin-left: auto;
}
// 应用列表(加载态用 v-loading,故需最小高度避免抖动)
.company-apps {
min-height: 80px;
}
// 单行应用:图标 + 名称/描述 + 操作
.company-app-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
&:last-child {
border-bottom: none;
}
}
.company-app-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: var(--el-fill-color-light);
color: var(--el-color-primary);
flex-shrink: 0;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.company-app-main {
flex: 1;
min-width: 0;
}
.company-app-name {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.company-app-desc {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.company-app-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
// 间距统一由 gap 控制(抵消 Element 相邻按钮的 margin-left)
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
// ===== 编辑弹窗图标上传 =====
.edit-icon-row {
display: flex;