Restore lightweight application management

This commit is contained in:
2026-06-10 08:09:03 +08:00
parent 9d00248e6e
commit 3ed272a875
5 changed files with 905 additions and 1 deletions
+1
View File
@@ -18,6 +18,7 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
'../views/_core/agent-chat/**/*.vue',
'../views/_core/announcement/index.vue',
'../views/_core/announcement/list.vue',
'../views/_core/application/index.vue',
'../views/_core/authentication/login.vue',
'../views/_core/authentication/oauth-callback.vue',
'../views/_core/chat/index.vue',
@@ -0,0 +1,514 @@
<script setup lang="ts">
import type { ApplicationListItem } from '#/api/core/application';
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import {
AppWindow,
Copy,
Edit,
IconifyIcon,
Play,
Plus,
Square,
Trash2,
} from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElCard,
ElEmpty,
ElInput,
ElMessage,
ElMessageBox,
ElPagination,
ElTag,
ElTooltip,
} from 'element-plus';
import {
deleteApplicationApi,
disableApplicationApi,
getApplicationListApi,
publishApplicationApi,
} from '#/api/core/application';
import ZqDialog from '#/components/zq-dialog/zq-dialog.vue';
import ApplicationFormModal from './modules/application-form-modal.vue';
defineOptions({ name: 'ApplicationList' });
// 搜索关键词
const searchKeyword = ref('');
// 应用列表
const applicationList = ref<ApplicationListItem[]>([]);
// 分页
const pagination = ref({
current: 1,
pageSize: 12,
total: 0,
});
// 加载状态
const loading = ref(false);
// 弹窗状态
const showFormModal = ref(false);
const editingApp = ref<ApplicationListItem | null>(null);
// 发布/停用对话框状态
const showPublishDialog = ref(false);
const showDisableDialog = ref(false);
const currentApp = ref<ApplicationListItem | null>(null);
const appUrl = ref('');
// Element Plus Tag 类型
type TagType =
| 'danger'
| 'info'
| 'primary'
| 'success'
| 'warning'
| undefined;
// 应用类型映射
const appTypeMap: Record<string, { color: TagType; labelKey: string }> = {
ai: { labelKey: 'application.appTypes.ai', color: 'info' },
dashboard: { labelKey: 'application.appTypes.dashboard', color: 'success' },
form: { labelKey: 'application.appTypes.form', color: 'primary' },
mixed: { labelKey: 'application.appTypes.mixed', color: undefined },
screen: { labelKey: 'application.appTypes.screen', color: 'danger' },
workflow: { labelKey: 'application.appTypes.workflow', color: 'warning' },
};
// 状态映射
const statusMap: Record<string, { color: TagType; labelKey: string }> = {
disabled: { labelKey: 'application.appStatus.disabled', color: 'danger' },
draft: { labelKey: 'application.appStatus.draft', color: 'info' },
published: { labelKey: 'application.appStatus.published', color: 'success' },
};
// 加载应用列表
const loadApplications = async () => {
loading.value = true;
try {
const res = await getApplicationListApi({
page: pagination.value.current,
pageSize: pagination.value.pageSize,
keyword: searchKeyword.value || undefined,
});
applicationList.value = res.items || [];
pagination.value.total = res.total || 0;
} catch (error) {
console.error('Failed to load applications:', error);
ElMessage.error($t('application.loadFailed'));
} finally {
loading.value = false;
}
};
// 创建新应用
const handleCreate = () => {
editingApp.value = null;
showFormModal.value = true;
};
// 编辑应用
const handleEdit = (app: ApplicationListItem) => {
editingApp.value = app;
showFormModal.value = true;
};
// 进入应用(在新 tab 打开子应用)
const handleEnter = (app: ApplicationListItem) => {
const subAppUrl = `${window.location.origin}/app/${app.code}`;
window.open(subAppUrl, '_blank');
};
// 设置开发(跳转到表单管理)
// 发布应用
const handlePublish = (app: ApplicationListItem) => {
currentApp.value = app;
appUrl.value = `${window.location.origin}/app/${app.code}`;
showPublishDialog.value = true;
};
// 确认发布
const confirmPublish = async () => {
if (!currentApp.value) return;
try {
await publishApplicationApi(currentApp.value.id);
const isReEnable = currentApp.value.status === 'disabled';
ElMessage.success(
isReEnable ? $t('application.enableSuccess') : $t('application.publishSuccess'),
);
showPublishDialog.value = false;
loadApplications();
} catch {
const wasDisabled = currentApp.value?.status === 'disabled';
ElMessage.error(
wasDisabled ? $t('application.enableFailed') : $t('application.publishFailed'),
);
}
};
// 停用应用
const handleDisable = (app: ApplicationListItem) => {
currentApp.value = app;
appUrl.value = `${window.location.origin}/app/${app.code}`;
showDisableDialog.value = true;
};
// 确认停用
const confirmDisable = async () => {
if (!currentApp.value) return;
try {
await disableApplicationApi(currentApp.value.id);
ElMessage.success($t('application.disableSuccess'));
showDisableDialog.value = false;
loadApplications();
} catch {
ElMessage.error($t('application.disableFailed'));
}
};
// 删除应用
const handleDelete = async (app: ApplicationListItem) => {
try {
await ElMessageBox.confirm(
$t('application.deleteConfirmMsg', { name: app.name }),
$t('application.deleteConfirm'),
{
confirmButtonText: $t('application.confirm'),
cancelButtonText: $t('application.cancel'),
type: 'warning',
},
);
await deleteApplicationApi(app.id);
ElMessage.success($t('application.deleteSuccess'));
loadApplications();
} catch {
// 取消操作
}
};
// 搜索
const handleSearch = () => {
pagination.value.current = 1;
loadApplications();
};
// 分页变化
const handlePageChange = (page: number) => {
pagination.value.current = page;
loadApplications();
};
// 每页条数变化
const handleSizeChange = (size: number) => {
pagination.value.pageSize = size;
pagination.value.current = 1;
loadApplications();
};
// 保存成功回调
const handleSaveSuccess = () => {
loadApplications();
};
// 复制链接
const copyLink = async () => {
try {
await navigator.clipboard.writeText(appUrl.value);
ElMessage.success($t('application.copySuccess'));
} catch {
ElMessage.error($t('application.copyFailed'));
}
};
onMounted(() => {
loadApplications();
});
</script>
<template>
<div class="application-list">
<Page auto-content-height v-loading="loading">
<template #title>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<ElButton type="primary" @click="handleCreate">
<Plus class="mr-1 h-4 w-4" />
{{ $t('application.createApp') }}
</ElButton>
</div>
<div class="flex items-center gap-2">
<ElInput
v-model="searchKeyword"
:placeholder="$t('application.searchPlaceholder')"
class="!w-64"
clearable
@clear="handleSearch"
@keyup.enter="handleSearch"
/>
<ElButton type="primary" @click="handleSearch">
{{ $t('application.search') }}
</ElButton>
</div>
</div>
</template>
<!-- 应用列表 -->
<div
v-if="applicationList.length > 0"
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
<ElCard
v-for="app in applicationList"
shadow="hover"
:key="app.id"
class="group application-card cursor-pointer transition-shadow"
:body-style="{ padding: '0' }"
style="border: none"
@click="handleEnter(app)"
>
<div class="p-4">
<!-- 头部图标 + 右侧信息区 -->
<div class="mb-4 flex gap-3">
<div class="app-icon flex-shrink-0">
<IconifyIcon
v-if="app.icon"
:icon="app.icon"
class="h-5 w-5 text-white"
/>
<AppWindow v-else class="h-5 w-5 text-white" />
</div>
<div class="min-w-0 flex-1">
<!-- name + 操作 -->
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1 whitespace-nowrap text-sm font-medium group-hover:truncate">
{{ app.name }}
</div>
<div
class="flex flex-shrink-0 items-center -space-x-1 opacity-0 transition-opacity group-hover:opacity-100"
@click.stop
>
<ElTooltip
:content="$t('application.edit')"
placement="top"
>
<ElButton text size="small" @click="handleEdit(app)">
<Edit class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
<ElTooltip
v-if="app.status === 'draft' || app.status === 'disabled'"
:content="
app.status === 'disabled'
? $t('application.enable')
: $t('application.publish')
"
placement="top"
>
<ElButton text size="small" @click="handlePublish(app)">
<Play class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
<ElTooltip
v-if="app.status === 'published'"
:content="$t('application.disable')"
placement="top"
>
<ElButton text size="small" @click="handleDisable(app)">
<Square class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
<ElTooltip
:content="$t('application.delete')"
placement="top"
>
<ElButton text size="small" @click="handleDelete(app)">
<Trash2 class="h-3.5 w-3.5" />
</ElButton>
</ElTooltip>
</div>
</div>
<!-- code -->
<div class="text-muted-foreground font-mono text-xs">
{{ app.code }}
</div>
</div>
</div>
<!-- 描述 -->
<div
v-if="app.description"
class="text-muted-foreground mb-4 line-clamp-1 text-xs"
>
{{ app.description }}
</div>
<!-- 第三行标签 + 创建时间 -->
<div class="flex items-center justify-between">
<div class="flex gap-1">
<ElTag size="small" :type="appTypeMap[app.app_type]?.color">
{{
appTypeMap[app.app_type]?.labelKey
? $t(appTypeMap[app.app_type]!.labelKey)
: app.app_type
}}
</ElTag>
<ElTag
size="small"
:type="statusMap[app.status]?.color ?? 'info'"
>
{{
statusMap[app.status]?.labelKey
? $t(statusMap[app.status]!.labelKey)
: app.status
}}
</ElTag>
</div>
<span class="text-muted-foreground text-xs">
{{ app.sys_create_datetime }}
</span>
</div>
</div>
</ElCard>
</div>
<!-- 空状态 -->
<ElEmpty v-else :description="$t('application.noApps')" />
<!-- 分页 -->
<template #footer>
<div class="flex w-full items-center justify-end">
<ElPagination
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[12, 24, 36, 48]"
:pager-count="7"
layout="total, sizes, prev, pager, next, jumper"
background
size="small"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</template>
</Page>
<!-- 创建/编辑弹窗 -->
<ApplicationFormModal
v-model="showFormModal"
:application="editingApp"
@success="handleSaveSuccess"
/>
<!-- 发布对话框 -->
<ZqDialog
v-model="showPublishDialog"
:title="
currentApp?.status === 'disabled'
? $t('application.enableApp')
: $t('application.publishApp')
"
width="500px"
:confirm-text="
currentApp?.status === 'disabled'
? $t('application.confirmEnable')
: $t('application.confirmPublish')
"
@confirm="confirmPublish"
@cancel="showPublishDialog = false"
>
<div class="mx-4 space-y-4">
<div>
<p class="mb-2 text-sm">
{{
currentApp?.status === 'disabled'
? $t('application.enableConfirmMsg', {
name: currentApp?.name,
})
: $t('application.publishConfirmMsg', {
name: currentApp?.name,
})
}}
</p>
<p class="text-muted-foreground text-xs">
{{
currentApp?.status === 'disabled'
? $t('application.enableSuccessMsg')
: $t('application.publishSuccessMsg')
}}
</p>
</div>
<div class="bg-secondary flex items-center justify-between rounded p-3">
<span class="mr-2 flex-1 truncate text-sm">{{ appUrl }}</span>
<ElButton text size="small" @click="copyLink">
<Copy class="h-4 w-4" />
</ElButton>
</div>
</div>
</ZqDialog>
<!-- 停用对话框 -->
<ZqDialog
v-model="showDisableDialog"
:title="$t('application.disableApp')"
width="500px"
:confirm-text="$t('application.confirmDisable')"
@confirm="confirmDisable"
@cancel="showDisableDialog = false"
>
<div class="mx-4 space-y-4">
<div>
<p class="mb-2 text-sm">
{{
$t('application.disableConfirmMsg', { name: currentApp?.name })
}}
</p>
<p class="text-muted-foreground text-xs">
{{ $t('application.appLink') }}
</p>
</div>
<div class="bg-secondary flex items-center justify-between rounded p-3">
<span class="mr-2 flex-1 truncate text-sm">{{ appUrl }}</span>
<ElButton text size="small" @click="copyLink">
<Copy class="h-4 w-4" />
</ElButton>
</div>
</div>
</ZqDialog>
</div>
</template>
<style scoped>
.application-card :deep(.el-card__body) {
padding: 0;
}
.app-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: linear-gradient(
135deg,
var(--el-color-primary-light-3),
var(--el-color-primary)
);
border-radius: 8px;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
@@ -0,0 +1,345 @@
<script lang="ts" setup>
import type {
ApplicationCreateInput,
ApplicationListItem,
ApplicationUpdateInput,
AppType,
} from '#/api/core/application';
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElCol,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElOption,
ElRow,
ElSelect,
} from 'element-plus';
import {
checkApplicationUniqueApi,
createApplicationApi,
updateApplicationApi,
} from '#/api/core/application';
import ZqDialog from '#/components/zq-dialog/zq-dialog.vue';
import { ZqIconPicker } from '#/components/zq-form/zq-icon-picker';
import ZqMenuSelector from '#/components/zq-form/zq-menu-selector/zq-menu-selector.vue';
defineOptions({ name: 'ApplicationFormModal' });
const props = defineProps<{
application?: ApplicationListItem | null;
}>();
const emit = defineEmits<{
success: [];
}>();
const visible = defineModel<boolean>({ default: false });
// Dialog 引用
const dialogRef = ref();
// 表单数据
const formData = ref<{
app_type: AppType;
code: string;
description: string;
icon: string;
name: string;
system_menu_ids: string[];
}>({
name: '',
code: '',
description: '',
icon: '',
app_type: 'mixed',
system_menu_ids: [],
});
// 表单引用
const formRef = ref();
// 是否编辑模式
const isEdit = computed(() => !!props.application);
// 弹窗标题
const title = computed(() =>
isEdit.value ? $t('application.editApp') : $t('application.createApp'),
);
// 应用类型选项
const appTypeOptions = computed<Array<{ label: string; value: AppType }>>(
() => [
{ label: $t('application.appTypes.mixed'), value: 'mixed' },
// { label: $t('application.appTypes.form'), value: 'form' },
{ label: $t('application.appTypes.workflow'), value: 'workflow' },
{ label: $t('application.appTypes.ai'), value: 'ai' },
// { label: $t('application.appTypes.dashboard'), value: 'dashboard' },
// { label: $t('application.appTypes.screen'), value: 'screen' },
],
);
// 表单验证规则
const rules = computed(() => ({
name: [
{
required: true,
message: $t('application.validation.nameRequired'),
trigger: 'blur',
},
{
min: 2,
max: 100,
message: $t('application.validation.nameLength'),
trigger: 'blur',
},
],
code: [
{
required: true,
message: $t('application.validation.codeRequired'),
trigger: 'blur',
},
{
pattern: /^[a-z][\w-]*$/i,
message: $t('application.validation.codePattern'),
trigger: 'blur',
},
{
min: 2,
max: 100,
message: $t('application.validation.codeLength'),
trigger: 'blur',
},
],
app_type: [
{
required: true,
message: $t('application.validation.typeRequired'),
trigger: 'change',
},
],
}));
// 监听弹窗打开,初始化表单数据
watch(visible, (val) => {
if (val) {
formData.value = props.application
? {
name: props.application.name,
code: props.application.code,
description: props.application.description || '',
icon: props.application.icon || '',
app_type: props.application.app_type as AppType,
system_menu_ids: (props.application as any).system_menu_ids || [],
}
: {
name: '',
code: '',
description: '',
icon: '',
app_type: 'mixed',
system_menu_ids: [],
};
}
});
// 检查编码唯一性
async function checkCodeUnique(code: string): Promise<boolean> {
try {
const res = await checkApplicationUniqueApi(
'code',
code,
props.application?.id,
);
return res.data?.unique ?? true;
} catch {
return true;
}
}
// 检查名称唯一性
async function checkNameUnique(name: string): Promise<boolean> {
try {
const res = await checkApplicationUniqueApi(
'name',
name,
props.application?.id,
);
return res.data?.unique ?? true;
} catch {
return true;
}
}
// 提交表单
async function handleConfirm() {
try {
await formRef.value?.validate();
} catch {
return;
}
dialogRef.value?.setConfirmLoading(true);
try {
// 检查编码唯一性
const codeUnique = await checkCodeUnique(formData.value.code);
if (!codeUnique) {
ElMessage.error('应用编码已存在');
dialogRef.value?.setConfirmLoading(false);
return;
}
// 检查名称唯一性
const nameUnique = await checkNameUnique(formData.value.name);
if (!nameUnique) {
ElMessage.error('应用名称已存在');
dialogRef.value?.setConfirmLoading(false);
return;
}
if (isEdit.value && props.application) {
const updateData: ApplicationUpdateInput = {
name: formData.value.name,
code: formData.value.code,
description: formData.value.description,
icon: formData.value.icon,
app_type: formData.value.app_type,
system_menu_ids: formData.value.system_menu_ids,
};
await updateApplicationApi(props.application.id, updateData);
ElMessage.success('更新成功');
} else {
const createData: ApplicationCreateInput = {
name: formData.value.name,
code: formData.value.code,
description: formData.value.description,
icon: formData.value.icon,
app_type: formData.value.app_type,
system_menu_ids: formData.value.system_menu_ids,
};
await createApplicationApi(createData);
ElMessage.success('创建成功');
}
visible.value = false;
emit('success');
} catch {
ElMessage.error(isEdit.value ? '更新失败' : '创建失败');
} finally {
dialogRef.value?.setConfirmLoading(false);
}
}
// 取消
function handleCancel() {
formRef.value?.resetFields();
visible.value = false;
}
</script>
<template>
<ZqDialog
ref="dialogRef"
v-model="visible"
:title="title"
width="640px"
:confirm-text="isEdit ? $t('application.save') : $t('application.create')"
@confirm="handleConfirm"
@cancel="handleCancel"
>
<ElForm
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
label-position="top"
>
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem :label="$t('application.appName')" prop="name">
<ElInput
v-model="formData.name"
:placeholder="$t('application.appNamePlaceholder')"
maxlength="100"
show-word-limit
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem :label="$t('application.appCode')" prop="code">
<ElInput
v-model="formData.code"
:placeholder="$t('application.appCodePlaceholder')"
maxlength="100"
show-word-limit
:disabled="isEdit"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem :label="$t('application.appType')" prop="app_type">
<ElSelect
v-model="formData.app_type"
:placeholder="$t('application.appTypePlaceholder')"
style="width: 100%"
>
<ElOption
v-for="opt in appTypeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem
:label="$t('application.appDescription')"
prop="description"
>
<ElInput
v-model="formData.description"
type="textarea"
:placeholder="$t('application.appDescriptionPlaceholder')"
:rows="3"
maxlength="500"
show-word-limit
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem :label="$t('application.appIcon')" prop="icon">
<ZqIconPicker
v-model="formData.icon"
prefix="lucide"
:auto-fetch-api="false"
class="w-full"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem
:label="$t('application.systemMenu')"
prop="system_menu_ids"
>
<ZqMenuSelector
v-model="formData.system_menu_ids"
:multiple="true"
:system-only="true"
:placeholder="$t('application.systemMenuPlaceholder')"
:dialog-title="$t('application.selectSystemMenu')"
dialog-width="500px"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</ZqDialog>
</template>