init: UserBaseFront (frontend)

This commit is contained in:
chenxing
2026-08-10 14:41:13 +08:00
commit eea881b616
174 changed files with 32833 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
<template>
<AppForm v-model="visible" :initForm="initFormData" :categoryList="categoryList" :isEdit="false"
:loading="submitting" @submit="handleSubmit" @cancel="visible = false" />
</template>
<script setup lang="ts">
import { ref } from "vue";
import AppForm from "./AppForm.vue";
/* ---------- 对外暴露:由父组件(AppList)调用 ---------- */
const visible = ref(false);
// 应用类型列表——由父组件通过 expose 传入,或内部自行拉取
const categoryList = ref<any[]>([]);
// 新增模式:始终传空初始值
const initFormData = ref<Record<string, any>>({});
// 成功后回调
let onSuccessCallback: (() => void) | null = null;
// useForm 托管提交
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
(dto) => Apis.AppSettings.addApp({ data: dto }),
{ initialForm: { name: '', categoryId: undefined, desc: '', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
);
onSuccess(() => {
Message.success('新增应用成功!');
visible.value = false;
onSuccessCallback?.();
});
onError((event) => {
Message.error(event?.error?.message || '新增失败');
});
/* ---------- 打开弹窗 ---------- */
function open(categories: any[], defaultCategoryId?: number | null, onSuccess?: () => void) {
categoryList.value = categories || [];
// 若列表已筛选某应用类型,则新增弹窗自动带入该类型;选「全部」时不带入
initFormData.value = defaultCategoryId != null ? { categoryId: defaultCategoryId } : {};
onSuccessCallback = onSuccess || null;
visible.value = true;
}
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
async function handleSubmit(formData: Record<string, any>) {
form.value = {
name: formData.name,
categoryId: formData.categoryId,
desc: formData.desc,
clients: formData.clients,
pcUrl: formData.pcUrl,
pcIcon: formData.pcIcon,
appUrl: formData.appUrl,
appIcon: formData.appIcon,
officialAccountUrl: formData.officialAccountUrl,
dingtalkUrl: formData.dingtalkUrl,
};
await sendForm();
}
defineExpose({ open, visible });
</script>
+253
View File
@@ -0,0 +1,253 @@
<template>
<div class="main-wrapper">
<div class="main-container">
<Alert show-icon>设置说明:拖动排序,被使用的分类不能删除</Alert>
<Button class="mb-5" type="primary" @click="addModuleShow">新增应用类型</Button>
<draggable class="type_set-wrap fix" tag="ul" v-model="typeList" :group="'appType'" @change="typeSortSave"
item-key="id">
<template #item="{ element }">
<li class="fl">
<div class="type_set-box">
<div class="type_name">
<span class="content">{{ element.name }}</span>
</div>
<div class="type_action flex">
<div class="action_edit" @click="showSetting(element)">
<span>编辑</span>
</div>
<div class="action_delete" @click="removeSave(element)">
<span>删除</span>
</div>
</div>
<div class="divider"></div>
</div>
</li>
</template>
</draggable>
</div>
<div class="modal-wrap">
<Modal v-model="editModal" :mask-closable="false" width="600"
:title="currentId == null ? '新增应用类型' : '编辑应用类型'">
<Form ref="formData" class="mt-8 mr-4 mb-12" :model="form" :rules="ruleValidate" :label-width="100">
<FormItem label="类型名称:" prop="name">
<Input placeholder="请输入应用类型名称" v-model="form.name" />
</FormItem>
</Form>
<template #footer>
<Button type="primary" :loading="submitting" @click="onEditSave">提交</Button>
<Button @click="onReset">取消</Button>
</template>
</Modal>
</div>
</div>
</template>
<script lang="ts" setup>
import draggable from 'vuedraggable'
import { debounce } from "lodash";
import MyConfirm from "@/components/MyConfirm";
const { data: typeList, send: sendTypeList } = useRequest(
Apis.AppSettings.appTypeList({
transform: (res: any) => {
return res ?? [];
},
cacheFor: null,
}),
{ initialData: [], immediate: true }
)
const refFormData = useTemplateRef('formData');
const editModal = ref(false);
const currentId = ref<number | null>(null);
const submitting = ref(false);
const form = ref<{ id: number | null; name: string }>({
id: null,
name: '',
});
const ruleValidate = ref({
name: [{ required: true, message: "请输入应用类型名称", trigger: "blur" }],
});
async function addModuleShow() {
currentId.value = null;
form.value = { id: null, name: '' };
await refFormData.value?.resetFields();
editModal.value = true;
}
//拖动排序:提交当前顺序的 id 数组
async function typeSortSave() {
const ids = (typeList.value ?? []).map((s: any) => s.id);
await Apis.AppSettings.appTypeSort({ data: ids });
}
async function showSetting(element: any) {
currentId.value = element.id;
form.value = {
id: element.id,
name: element.name,
};
editModal.value = true;
}
/**
* removeSave
*/
async function removeSave(element: any) {
await MyConfirm({
msg: "确定删除该应用类型吗?",
info: "删除后不可恢复!",
async onOk() {
try {
await Apis.AppSettings.deleteType({ pathParams: { id: element.id } });
Message.success("操作成功!");
await sendTypeList();
} catch (error) {
console.error(error);
}
},
});
}
const onEditSave = debounce(onRawEditSave, 500)
async function onRawEditSave() {
try {
const validate = await refFormData.value?.validate();
if (!validate) return false;
} catch {
return false
}
submitting.value = true;
try {
if (currentId.value == null) {
await Apis.AppSettings.addType({
data: {
name: form.value.name,
},
});
} else {
await Apis.AppSettings.editType({
pathParams: { id: currentId.value },
data: {
id: currentId.value,
name: form.value.name,
},
});
}
Message.success("操作成功!");
editModal.value = false;
await sendTypeList();
} catch (error) {
console.error(error);
} finally {
submitting.value = false;
}
}
function onReset() {
refFormData.value?.resetFields();
currentId.value = null;
editModal.value = false;
}
</script>
<style lang="scss" scoped>
.flex {
display: flex;
}
.type_set-wrap {
margin: 12px 0 16px;
}
.type_set-box {
width: 222px;
background: #ffffff;
margin-right: 15px;
margin-bottom: 15px;
.type_name {
height: 66px;
border: 1px solid #e9e9e9;
border-bottom: none;
padding-left: 15px;
padding-right: 15px;
line-height: 66px;
display: flex;
justify-content: center;
align-items: center;
cursor: move;
span.content {
height: 24px;
font-size: 15px;
font-family: PingFangSC-Semibold, PingFang SC;
font-weight: 500;
color: #272829;
line-height: 24px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 222px;
display: inline-block;
flex: 1 1 0;
}
.iconfont {
font-size: 26px;
}
}
.type_action {
flex-wrap: wrap;
justify-content: center;
align-items: center;
width: 222px;
height: 30px;
line-height: 30px;
background: #f7f7f7;
border-radius: 0px 0px 2px 2px;
border-top: none;
border: 1px solid #e9e9e9;
span {
font-size: 12px;
}
div {
cursor: pointer;
flex: 1;
text-align: center;
}
div.disabled {
color: #ccc;
cursor: not-allowed;
&:hover {
color: #ccc;
}
}
div:hover {
color: #6c91f0;
}
}
.divider {
height: 14px;
width: 1px;
background: #e8e8e8;
float: left;
margin-left: 111px;
margin-top: -22px;
}
}
</style>
+335
View File
@@ -0,0 +1,335 @@
<template>
<Modal v-model="show" :mask-closable="false" width="800" scrollable :title="title"
@on-visible-change="onVisibleChange">
<Form ref="formRef" :model="form" :rules="rules" :label-width="130" class="app-form width-95">
<!-- 第一行:应用名称 / 应用类型 -->
<Row>
<Col span="12">
<FormItem label="应用名称:" prop="name">
<Input v-model="form.name" placeholder="请输入应用名称" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="应用类型:" prop="categoryId">
<Select v-model="form.categoryId" transfer placeholder="请选择应用类型">
<Option v-for="item in categoryList" :key="item.id" :value="item.id" :label="item.name" />
</Select>
</FormItem>
</Col>
</Row>
<!-- 第二行:应用描述(整行) -->
<Row>
<Col span="24">
<FormItem label="应用描述:" prop="desc">
<Input v-model="form.desc" type="text" placeholder="请输入应用描述" />
</FormItem>
</Col>
</Row>
<!-- 第三行:appKey / appSecret(编辑模式才显示) -->
<Row v-if="isEdit">
<Col span="12">
<FormItem label="appKey:">
<Input :model-value="form.appKey?.toString()" readonly />
</FormItem>
</Col>
<Col span="12">
<FormItem label="appSecret:">
<div class="secret-row">
<Input :model-value="form.appSecret" readonly style="flex:1" />
<Button type="default" class="ml-8" @click="$emit('reset-secret')">重置</Button>
</div>
</FormItem>
</Col>
</Row>
<!-- 第四行:应用前端类型(tag 多选) -->
<Row>
<Col span="24">
<FormItem label="应用前端类型:">
<Select v-model="form.clients" multiple transfer placeholder="请选择前端类型" class="client-select">
<Option v-for="opt in clientOptions" :key="opt.value" :value="opt.value" :label="opt.label" />
</Select>
</FormItem>
</Col>
</Row>
<!-- 第五行:Pc应用地址 / Pc应用图标 -->
<Row>
<Col span="12">
<FormItem label="Pc应用地址:">
<Input v-model="form.pcUrl" placeholder="请输入Pc应用地址" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="Pc应用图标:">
<Upload :on-success="(res: any) => onUploadSuccess(res, 'pcIcon')" :show-upload-list="false"
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
:with-credentials="true" :headers="{ yschapirequest: 1 }">
<div v-if="!form.pcIcon" class="icon-upload-wrap">
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
</div>
<div class="icon-preview-wrap" v-else>
<SvgIcon @click.stop="form.pcIcon = null" class="cloase-btn" name="widget-close" size="18"
color="#fff" />
<img :src="previewUrl(form.pcIcon)" alt="" />
</div>
</Upload>
</FormItem>
</Col>
</Row>
<!-- 第六行:App接口地址 / App应用图标 -->
<Row>
<Col span="12">
<FormItem label="App接口地址:">
<Input v-model="form.appUrl" placeholder="请输入App接口地址" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="App应用图标:">
<Upload :on-success="(res: any) => onUploadSuccess(res, 'appIcon')" :show-upload-list="false"
:action="uploadUrl" :data="{ type: 'icon' }" accept=".svg,.png,.jpg,.jpeg"
:with-credentials="true" :headers="{ yschapirequest: 1 }">
<div v-if="!form.appIcon" class="icon-upload-wrap">
<SvgIcon name="upload-add" color="#646566" style="width: 20px; height: 20px" />
</div>
<div class="icon-preview-wrap" v-else>
<SvgIcon @click.stop="form.appIcon = null" class="cloase-btn" name="widget-close" size="18"
color="#fff" />
<img :src="previewUrl(form.appIcon)" alt="" />
</div>
</Upload>
</FormItem>
</Col>
</Row>
<!-- 第七行:公众号应用地址 / 钉钉应用地址 -->
<Row>
<Col span="12">
<FormItem label="公众号应用地址:">
<Input v-model="form.officialAccountUrl" placeholder="请输入公众号应用地址" />
</FormItem>
</Col>
<Col span="12">
<FormItem label="钉钉应用地址:">
<Input v-model="form.dingtalkUrl" placeholder="请输入钉钉应用地址" />
</FormItem>
</Col>
</Row>
</Form>
<template #footer>
<Button type="primary" :loading="loading" @click="handleSubmit">提交</Button>
<Button @click="handleCancel">取消</Button>
</template>
</Modal>
</template>
<script setup lang="ts">
import SvgIcon from "@/components/SvgIcon.vue";
import { computed, reactive, watch } from "vue";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
const { $bus, $utils, $conf } = useCurrentInstance();
/* ---------- Props / Emits ---------- */
const props = withDefaults(defineProps<{
modelValue: boolean;
initForm?: Record<string, any>;
categoryList?: any[];
isEdit?: boolean;
loading?: boolean;
}>(), {
initForm: () => ({}),
categoryList: () => [],
isEdit: false,
loading: false,
});
const emit = defineEmits(['update:modelValue', 'submit', 'cancel', 'reset-secret']);
const show = computed({
get: () => props.modelValue,
set: val => emit('update:modelValue', val),
});
const title = computed(() => props.isEdit ? '编辑应用' : '新增应用');
/* ---------- 表单数据 ---------- */
const defaultForm = (): Record<string, any> => ({
id: null,
name: '',
categoryId: undefined as number | undefined,
desc: '',
appKey: null,
appSecret: '',
clients: [] as number[],
pcUrl: '',
pcIcon: '',
appUrl: '',
appIcon: '',
officialAccountUrl: '',
dingtalkUrl: '',
});
const form = reactive(defaultForm());
// 外部 initForm 变化时回填
watch(() => props.initForm, (val) => {
if (val && Object.keys(val).length > 0) {
Object.assign(form, defaultForm(), val);
} else {
Object.assign(form, defaultForm());
}
}, { immediate: true, deep: true });
/* ---------- 上传相关 ---------- */
// 上传地址使用 $conf.serviceHost 作为基地址
const uploadUrl = computed(() => $conf?.serviceHost + '/api/base/UploadFile');
function previewUrl(path: string | undefined): string {
if (!path) return '';
if (path.startsWith('http')) return path;
return ($conf?.serviceHost || '') + path;
}
function onUploadSuccess(response: any, field: 'pcIcon' | 'appIcon') {
if (response?.code === 0 || response?.code === 200) {
(form as any)[field] = response.data ?? response.url ?? '';
} else {
Message.error((response?.msg || response?.message) || '上传失败');
}
}
/* ---------- 前端类型选项 ---------- */
const clientOptions = [
{ value: 1, label: 'Pc浏览器' },
{ value: 2, label: '移动App' },
];
/* ---------- 校验规则 ---------- */
const rules = {
name: [{ required: true, message: '请输入应用名称', trigger: 'blur' }],
categoryId: [{ required: true, type: 'number' as const, message: '请选择应用类型', trigger: 'change' }],
};
/* ---------- 提交 ---------- */
const formRef = ref();
async function handleSubmit() {
try {
await formRef.value?.validate();
} catch {
return false;
}
// 提交前清理空值
const submitData = { ...form };
emit('submit', submitData);
}
function handleCancel() {
emit('cancel');
}
function onVisibleChange(val: boolean) {
if (!val) {
emit('cancel');
}
}
</script>
<style lang="scss" scoped>
/* 表单整体:宽度 95% 避免横向滚动(width-95 工具类控制宽度,scss 生成) */
.app-form {
margin: 0 auto;
max-height: 60vh;
overflow-y: auto;
overflow-x: hidden;
padding-right: 12px;
/* FormItem 改为 block,避免默认 inline-block 在 flex Col 内垂直间距失效 */
:deep(.ivu-form-item) {
display: block;
margin-bottom: 0;
}
:deep(.ivu-form-item-label) {
font-size: 13px;
color: #515a6e;
}
/* 行间距改由 Row 统一控制(Row 是 flex 容器,FormItem 默认无 margin-bottom) */
:deep(.ivu-row) {
margin-left: 0 !important;
margin-right: 0 !important;
margin-bottom: 22px;
}
:deep(.ivu-row:last-child) {
margin-bottom: 0;
}
}
.secret-row {
display: flex;
align-items: center;
width: 100%;
}
/* 图标上传区域 */
.icon-upload-wrap {
width: 48px;
height: 48px;
border: 1px dashed #dcdee2;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.2s;
&:hover {
border-color: #577afb;
}
}
.icon-preview-wrap {
position: relative;
width: 48px;
height: 48px;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cloase-btn {
position: absolute;
top: 0;
right: 0;
background: rgba(0, 0, 0, 0.45);
border-radius: 0 0 0 6px;
padding: 2px;
cursor: pointer;
&:hover {
background: rgba(220, 50, 50, 0.8);
}
}
}
/* 客户端多选样式 */
.client-select {
width: 100%;
:deep(.ivu-select-selection) {
max-height: 100px;
overflow-y: auto;
}
}
</style>
+304
View File
@@ -0,0 +1,304 @@
<template>
<div class="page-wrapper tree-wrapper">
<div class="tree-container app-type-tree">
<div class="tree-scroll">
<ul class="grade-ul">
<li :class="dataParams.categoryId == null ? 'active' : ''" @click="onCategoryClick(null)">
全部
</li>
<li v-for="item in appTypeList" :key="item.id"
:class="item.id == dataParams.categoryId ? 'active' : ''" @click="onCategoryClick(item.id)">
{{ item.name }}
</li>
</ul>
</div>
<div class="tree-footer">
<Button type="text" @click="goAppType">管理应用类型</Button>
</div>
</div>
<div class="main-container">
<div class="search-wrap fix">
<Button class="fl" type="primary" @click="handleAdd">新增应用</Button>
<Button class="fl ml-8" type="default" v-if="dataParams.categoryId != null"
@click="sortModalVisible = true">排序管理</Button>
<Input class="fr" v-model="keyword" placeholder="输入应用名称或appKey筛选" search clearable suffix="ios-search"
style="width:220px" @on-search="onSearch" @on-clear="onSearch" />
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columnsComp" :tableHeight="tableHeight" />
</div>
<!-- 容器组件:新增 / 编辑 -->
<AddAppContainer ref="addRef" />
<EditAppContainer ref="editRef" />
<!-- 排序管理 -->
<AppSort v-model="sortModalVisible" :categoryId="dataParams.categoryId" :categoryName="currentCategoryName"
@saved="onSortSaved" />
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from "vue";
defineOptions({ name: "AppList" });
import { useRouter } from "vue-router";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import MyConfirm from "@/components/MyConfirm";
import AddAppContainer from "./AddAppContainer.vue";
import EditAppContainer from "./EditAppContainer.vue";
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import { OperatorEnum } from '@/types';
import AppSort from './AppSort.vue';
const router = useRouter();
const { $utils } = useCurrentInstance();
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const sortModalVisible = ref(false);
const keyword = ref("");
const dataParams = ref<{
categoryId: number | null;
name?: string;
}>({
categoryId: null,
name: "",
});
/* ---------------- 左侧分类切换:立即刷新 ---------------- */
function onCategoryClick(id: number | null) {
dataParams.value.categoryId = id;
tableRef.value?.refreshData();
}
/* ---------------- 输入框搜索:仅在触发搜索时提交 ---------------- */
function onSearch() {
dataParams.value.name = keyword.value.trim() || undefined;
tableRef.value?.refreshData();
}
/* ---------------- 左侧应用类型 ---------------- */
const { data: appTypeList, send: fetchTypeList } = useRequest(
Apis.AppSettings.appTypeList({
transform: (res: any) => {
const list: any[] = res ?? [];
return list.slice().sort((a, b) => (a.sortNo ?? 0) - (b.sortNo ?? 0));
},
cacheFor: null,
}),
{ initialData: [] }
);
const tableApi = (params: { pageIndex: number; pageSize: number }) => {
return Apis.AppSettings.appList({
params: {
categoryId: dataParams.value.categoryId ?? undefined,
name: dataParams.value.name || undefined,
pageIndex: params.pageIndex,
pageSize: params.pageSize,
},
cacheFor: null,
});
};
const typeNameMap = computed(() => {
const map: Record<number, string> = {};
(appTypeList.value ?? []).forEach((t: any) => (map[t.id] = t.name));
return map;
});
const currentCategoryName = computed(() => {
if (dataParams.value.categoryId == null) return "";
return typeNameMap.value[dataParams.value.categoryId] ?? "";
});
/* ---------------- 排序保存后刷新列表 ---------------- */
function onSortSaved() {
tableRef.value?.reloadData(OperatorEnum.sel);
}
const tableHeight = ref(
$utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true })
);
/* ---------------- 表格固定列(不含“应用类型”列) ---------------- */
const columns = [
{ type: "index", title: "序号", width: 70 },
{ title: "应用名称", key: "name", width: 160 },
{ title: "描述", key: "desc", ellipsis: true, tooltip: true, minWidth: 140 },
{
title: "前端",
key: "clients",
width: 180,
render: (h: any, { row }: any) => {
const map: Record<number, string> = { 1: "Pc浏览器", 2: "移动App" };
const arr: number[] = Array.isArray(row.clients) ? row.clients : [];
if (!arr.length) return h("span", "-");
return h("span", arr.map((v) => map[v] ?? v).join("、"));
},
},
{
title: "操作",
key: "action",
align: 'center',
width: 280,
render: (h: any, { row }: any) => {
return [
$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row)),
$utils.operatingTextHandle(h, '模块管理', '', () => goModuleManage(row)),
$utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row)),
];
}
},
];
// 条件列:仅在「未筛选应用类型」时插入到“应用名称”之后
const typeColumn = {
title: "应用类型",
key: "categoryId",
width: 110,
render: (h: any, { row }: any) => h("span", typeNameMap.value[row.categoryId] ?? "-"),
};
/* ---------------- 表格最终数据源(动态列) ---------------- */
const columnsComp = computed(() => {
const cols = [...columns];
// 未筛选应用类型时,展示“应用类型”列
if (!dataParams.value.categoryId) {
const idx = cols.findIndex((c) => c.key === "name");
cols.splice(idx + 1, 0, typeColumn);
}
return cols;
});
/* ---------------- 新增应用 ---------------- */
const addRef = ref();
function handleAdd() {
addRef.value?.open(appTypeList.value, dataParams.value.categoryId, () => {
tableRef.value?.reloadData(OperatorEnum.add);
});
}
/* ---------------- 编辑应用 ---------------- */
const editRef = ref();
function handleEdit(row: any) {
editRef.value?.open(row.id, row, appTypeList.value, () => {
tableRef.value?.reloadData(OperatorEnum.edit);
});
}
/* ---------------- 删除应用 ---------------- */
async function handleDelete(row: any) {
await MyConfirm({
msg: `确定删除应用「${row.name || row.id}」吗?`,
info: "删除后不可恢复!",
async onOk() {
try {
await Apis.AppSettings.deleteApp({ pathParams: { id: row.id } });
Message.success('删除成功!');
tableRef.value?.reloadData(OperatorEnum.del);
} catch (e: any) {
Message.error(e?.message || '删除失败');
}
},
});
}
/* ---------------- 导航 ---------------- */
const goAppType = () => {
router.push("/app-manage/category");
};
const goModuleManage = (row: any) => {
router.push({
path: "/app-manage/module",
query: { id: row.id, name: row.name },
});
};
</script>
<style lang="scss" scoped>
.app-type-tree {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
width: 180px;
}
.tree-scroll {
flex: 1;
overflow: auto;
min-height: 0;
padding: 10px 12px;
}
.tree-footer {
flex-shrink: 0;
padding-top: 8px;
text-align: center;
background: #ffffff;
border-top: 1px solid #eeeeee;
.ivu-btn-text {
color: #577afb;
}
}
.grade-ul {
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
li {
cursor: pointer;
background-color: #fafafa;
border: 1px solid #ebeef5;
border-radius: 4px;
height: 40px;
line-height: 40px;
text-align: center;
}
li.active {
color: #577afb;
border-color: #577afb;
}
}
.action-cell {
display: flex;
align-items: center;
justify-content: center;
}
.action-link {
cursor: pointer;
color: #577afb;
font-size: 13px;
&:hover {
color: #3d5afe;
}
&.danger {
color: #ed4014;
&:hover {
color: #c51d04;
}
}
}
.action-divider {
margin: 0 8px;
color: #dcdee2;
}
</style>
+218
View File
@@ -0,0 +1,218 @@
<template>
<Modal v-model="modalVisible" :title="'应用排序 - ' + categoryName" width="560" :mask-closable="false"
@on-visible-change="onVisibleChange">
<Alert show-icon>拖动左侧手柄进行排序,排序结果会实时记录</Alert>
<div class="sort-loading" v-if="loading">
<span>加载中...</span>
</div>
<draggable v-else class="sort-list" v-model="sortList" item-key="id" handle=".drag-handle" @change="onDragChange">
<template #item="{ element, index }">
<div class="sort-item">
<span class="drag-handle">⠿</span>
<span class="sort-index">{{ index + 1 }}</span>
<span class="sort-name">{{ element.name || element.id }}</span>
<span class="sort-no">排序号: {{ element.sortNo ?? '-' }}</span>
</div>
</template>
</draggable>
<div class="sort-empty" v-if="!loading && sortList.length === 0">
暂无应用数据
</div>
<template #footer>
<Button type="primary" :loading="saving" @click="handleSave" :disabled="sortList.length === 0">
保存排序
</Button>
<Button @click="modalVisible = false">取消</Button>
</template>
</Modal>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue";
import draggable from "vuedraggable";
const props = withDefaults(
defineProps<{
modelValue: boolean;
categoryId: number | null;
categoryName: string;
}>(),
{
categoryName: "",
}
);
const emit = defineEmits<{
(e: "update:modelValue", val: boolean): void;
(e: "saved"): void;
}>();
const modalVisible = ref(false);
const loading = ref(false);
const saving = ref(false);
const sortList = ref<{ id: number; name?: string; sortNo?: number }[]>([]);
const hasChanged = ref(false);
watch(
() => props.modelValue,
(val) => {
modalVisible.value = val;
if (val) {
fetchSortList();
}
}
);
watch(modalVisible, (val) => {
emit("update:modelValue", val);
});
function onVisibleChange(visible: boolean) {
if (!visible) {
hasChanged.value = false;
}
}
async function fetchSortList() {
if (!props.categoryId) return;
loading.value = true;
try {
const res = await Apis.AppSettings.appSortList({
params: { categoryId: props.categoryId },
cacheFor: null,
});
sortList.value = (res ?? []).slice().sort((a: any, b: any) => (a.sortNo ?? 0) - (b.sortNo ?? 0));
hasChanged.value = false;
} catch (e: any) {
Message.error(e?.message || "获取排序列表失败");
} finally {
loading.value = false;
}
}
function onDragChange() {
hasChanged.value = true;
}
async function handleSave() {
if (!hasChanged.value) {
Message.info("排序未发生变化");
modalVisible.value = false;
return;
}
saving.value = true;
try {
const ids = sortList.value.map((item) => item.id);
await Apis.AppSettings.appSort({ data: ids });
Message.success("排序保存成功!");
hasChanged.value = false;
modalVisible.value = false;
emit("saved");
} catch (e: any) {
Message.error(e?.message || "排序保存失败");
} finally {
saving.value = false;
}
}
</script>
<style lang="scss" scoped>
.sort-list {
max-height: 400px;
overflow-y: auto;
}
.sort-item {
display: flex;
align-items: center;
padding: 10px 12px;
margin-bottom: 6px;
background: #fafafa;
border: 1px solid #e8e8e8;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: #f0f4ff;
border-color: #c0d0ff;
}
}
/* 拖拽中:当前被拖拽的元素高亮(明显橙色,不放大避免横向滚动条) */
.sort-list :deep(.sort-item.sortable-chosen),
.sort-list :deep(.sort-item.sortable-drag) {
background: #fff7e6;
border-color: #fa8c16;
box-shadow: 0 6px 16px rgba(250, 140, 22, 0.4);
cursor: grabbing;
.drag-handle {
color: #fa8c16;
}
}
/* 拖拽中:落点占位位置(虚线提示) */
.sort-list :deep(.sort-item.sortable-ghost) {
background: #fffbe6;
border: 1px dashed #fa8c16;
opacity: 0.6;
}
.drag-handle {
cursor: grab;
display: flex;
align-items: center;
padding: 0 8px;
color: #999;
flex-shrink: 0;
&:active {
cursor: grabbing;
}
}
.sort-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: #577afb;
color: #fff;
font-size: 13px;
font-weight: 500;
flex-shrink: 0;
margin-right: 12px;
}
.sort-name {
flex: 1;
font-size: 14px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sort-no {
flex-shrink: 0;
font-size: 12px;
color: #999;
margin-left: 12px;
}
.sort-loading,
.sort-empty {
padding: 40px 0;
text-align: center;
color: #999;
font-size: 14px;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<template>
<AppForm v-model="visible" :initForm="detailData" :categoryList="categoryList" :isEdit="true" :loading="submitting"
@submit="handleSubmit" @cancel="visible = false" @reset-secret="handleResetSecret" />
</template>
<script setup lang="ts">
import { ref } from "vue";
import AppForm from "./AppForm.vue";
import MyConfirm from '@/components/MyConfirm'
/* ---------- 对外暴露 ---------- */
const visible = ref(false);
const categoryList = ref<any[]>([]);
// 编辑模式:从 appList 中根据 id 取出详情回填(无独立详情接口)
const detailData = ref<Record<string, any>>({});
const currentId = ref<number | null>(null);
// 成功后回调
let onSuccessCallback: (() => void) | null = null;
// useForm 托管提交——currentId 是 ref,闭包内 .value 在 sendForm 时取当前值
const { form, loading: submitting, send: sendForm, onSuccess, onError } = useForm(
(dto) => Apis.AppSettings.editApp({ pathParams: { id: currentId.value }, data: dto }),
{ initialForm: { id: null, name: '', categoryId: undefined, desc: '', clients: [], pcUrl: '', pcIcon: '', appUrl: '', appIcon: '', officialAccountUrl: '', dingtalkUrl: '' }, resetAfterSubmiting: true }
);
onSuccess(() => {
Message.success('编辑应用成功!');
visible.value = false;
onSuccessCallback?.();
});
onError((event) => {
Message.error(event?.error?.message || '编辑失败');
});
/* ---------- 打开编辑弹窗 ---------- */
/**
* @param id - 应用 ID
* @param row - 当前行完整数据(来自表格列表),直接用作回填数据
* @param categories - 应用类型列表
* @param onSuccess - 编辑成功后的回调
*/
function open(id: number, row: any, categories: any[], onSuccess?: () => void) {
currentId.value = id;
categoryList.value = categories || [];
// 直接用行数据回填(appList 接口返回的字段与表单一致)
detailData.value = { ...row };
onSuccessCallback = onSuccess || null;
visible.value = true;
}
/* ---------- 提交:由 useForm 接管 loading 与异常处理 ---------- */
async function handleSubmit(formData: Record<string, any>) {
if (!currentId.value) return;
form.value = {
id: currentId.value,
name: formData.name,
categoryId: formData.categoryId,
desc: formData.desc,
clients: formData.clients,
pcUrl: formData.pcUrl,
pcIcon: formData.pcIcon,
appUrl: formData.appUrl,
appIcon: formData.appIcon,
officialAccountUrl: formData.officialAccountUrl,
dingtalkUrl: formData.dingtalkUrl,
};
await sendForm();
}
/* ---------- 重置密钥(非表单提交,直接 MyConfirm + await) ---------- */
async function handleResetSecret() {
if (!currentId.value) return;
try {
await MyConfirm({
msg: '确定要重置该应用的 Secret 吗?',
info: '重置后原 Secret 将立即失效!',
async onOk() {
const newSecret = await Apis.AppSettings.resetAppSecret({
pathParams: { id: currentId.value! },
});
Message.success('密钥已重置!新 Secret: ' + (newSecret || ''));
// 更新表单中的 secret 回显值
if (newSecret) {
detailData.value = { ...detailData.value, appSecret: newSecret };
}
return true;
},
});
} catch {
// 用户取消
}
}
defineExpose({ open, visible });
</script>
+295
View File
@@ -0,0 +1,295 @@
<template>
<div class="main-wrapper">
<div class="main-container">
<div class="search-wrap">
<Button class="fl" type="primary" @click="handleAdd">新增模块</Button>
<Button class="fr" icon="ios-arrow-back" @click="goBack">返回</Button>
</div>
<h4 class="mt-10 second-title">{{ appName }} - 模块管理</h4>
<Table class="module-table" :columns="columns" :data="flatData" border>
<template #name="{ row }">
<div class="module-name-cell" :style="{ paddingLeft: row._depth * 28 + 'px' }">
<SvgIcon v-if="row._depth > 0" name="tree-line" :size="16" color="#999" />
<Icon type="ios-cube-outline" size="16" />
<span class="module-name-text">{{ row.name }}</span>
</div>
</template>
</Table>
</div>
<!-- 新增 / 编辑弹窗 -->
<Modal v-model="editModal" :mask-closable="false" width="600" :title="modalTitle">
<Form ref="formData" class="mt-8 mr-4 mb-12" :model="form" :rules="ruleValidate" :label-width="110">
<FormItem v-if="form.parentId != null" label="父模块名称:">
<Input :model-value="parentNameForDisplay" disabled />
</FormItem>
<FormItem label="模块名称:" prop="name">
<Input placeholder="请输入模块名称" v-model="form.name" />
</FormItem>
<FormItem label="模块路径:" prop="path">
<Input placeholder="请输入模块路径" v-model="form.path" />
</FormItem>
<FormItem label="自定义ID:" prop="customId">
<InputNumber :style="{ width: '100%' }" placeholder="请输入自定义ID" v-model="form.customId" />
</FormItem>
<FormItem label="排序号:" prop="sortNo">
<InputNumber :style="{ width: '100%' }" placeholder="请输入排序号" v-model="form.sortNo" />
</FormItem>
</Form>
<template #footer>
<Button type="primary" :loading="submitting" @click="onSave">提交</Button>
<Button @click="onReset">取消</Button>
</template>
</Modal>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { debounce } from "lodash";
import SvgIcon from "@/components/SvgIcon.vue";
import MyConfirm from "@/components/MyConfirm";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
const { $utils } = useCurrentInstance();
const route = useRoute();
const router = useRouter();
const appId = computed(() => Number(route.query.id));
const appName = computed(() => (route.query.name as string) || '');
/* ---------------- 数据加载(无分页) ---------------- */
const { data: moduleList, send: fetchModuleList } = useRequest(
Apis.AppSettings.moduleList({
params: { appId: appId.value },
cacheFor: null,
}),
{ initialData: [], immediate: true }
);
/* ---------------- 递归扁平化(伪树形,支持任意层级) ----------------
* 注意:必须剥掉 children,否则 view-ui-plus Table 会把行当成树节点处理,
* 触发内部树形展开逻辑并读到 null 子节点,报 `_isChecked` of null 错误。 */
function flattenTree(list: any[], depth = 0): any[] {
const result: any[] = [];
(list ?? []).forEach((node: any) => {
const { children, ...rest } = node;
const hasChildren = Array.isArray(children) && children.length > 0;
result.push({ ...rest, _depth: depth, _isParent: node.parentId == null, _hasChildren: hasChildren });
if (hasChildren) {
result.push(...flattenTree(children, depth + 1));
}
});
return result;
}
const flatData = computed(() => flattenTree(moduleList.value));
/* ---------------- 表格列 ---------------- */
const columns = [
{ title: "模块名称", key: "name", minWidth: 260, slot: "name" },
{ title: "ID", key: "customId", width: 120, align: "center", render: (h: any, { row }: any) => h("span", row.customId || "-") },
{ title: "路径", key: "path", minWidth: 200, render: (h: any, { row }: any) => h("span", row.path || "-") },
{ title: "排序号", key: "sortNo", width: 100, align: "center", render: (h: any, { row }: any) => h("span", row.sortNo ?? "-") },
{
title: "操作",
key: "action",
width: 220,
align: "center",
render: (h: any, { row }: any) => {
const actions: any[] = [
$utils.operatingTextHandle(h, '编辑', '', () => handleEdit(row)),
$utils.operatingTextHandle(h, '删除', 'text-error', () => handleDelete(row)),
];
if (row.level < 5) {
actions.splice(1, 0, $utils.operatingTextHandle(h, '添加子模块', '', () => handleAddChild(row)));
}
return h("div", { style: { display: "flex", justifyContent: "center", gap: "4px" } }, actions);
},
},
];
/* ---------------- 弹窗状态 ---------------- */
const refFormData = useTemplateRef('formData');
const editModal = ref(false);
const currentId = ref<number | null>(null);
const submitting = ref(false);
const parentNameForDisplay = ref('');
const form = ref<{
id: number | null;
name: string;
path: string;
customId: number | null;
parentId: number | null;
sortNo: number | null;
}>({
id: null,
name: '',
path: '',
customId: null,
parentId: null,
sortNo: null,
});
const ruleValidate = ref({
name: [{ required: true, message: "请输入模块名称", trigger: "blur" }],
path: [{ required: true, message: "请输入模块路径", trigger: "blur" }],
customId: [{ required: true, type: "number", message: "请输入自定义ID", trigger: "change" }],
});
const openingIsChild = ref(false);
const modalTitle = computed(() => {
if (currentId.value != null) return '编辑模块';
return openingIsChild.value ? '新增子模块' : '新增模块';
});
/* ---------------- 返回应用列表(keepAlive 记忆筛选) ---------------- */
function goBack() {
router.push("/app-manage/list");
}
/* ---------------- 由嵌套 moduleList 递归构建 id->name 映射(兜底后端未返回 parentName) ---------------- */
function buildIdNameMap(list: any[]): Record<number, string> {
const map: Record<number, string> = {};
(list ?? []).forEach((n: any) => {
if (n.id != null) map[n.id] = n.name;
if (Array.isArray(n.children) && n.children.length) Object.assign(map, buildIdNameMap(n.children));
});
return map;
}
const parentNameMap = computed(() => buildIdNameMap(moduleList.value));
/* ---------------- 弹窗赋值统一入口(先打开弹窗 → nextTick 内 resetFields 清空校验 → 再写入值) ----------------
* 注意:view-ui-plus 的 resetFields() 会把所有字段重置回 mount 初值(parentId 会变成 null),
* 因此必须先 resetFields,再赋值,否则值被清掉:
* - 「父模块名称」整行因 v-if(parentId!=null) 不成立而消失
* - 保存时子模块的 parentId 被清空、变成顶层模块 */
function openEditModal(item: any) {
currentId.value = item?.id ?? null;
editModal.value = true;
nextTick(() => {
refFormData.value?.resetFields();
const f = form.value;
f.id = item?.id ?? null;
f.name = item?.name || '';
f.path = item?.path || '';
f.customId = item?.customId != null ? Number(item.customId) : null;
f.parentId = item?.parentId ?? null;
f.sortNo = item?.sortNo != null ? Number(item.sortNo) : null;
parentNameForDisplay.value = f.parentId != null
? (parentNameMap.value[f.parentId] || '')
: '';
});
}
/* ---------------- 新增模块 ---------------- */
function handleAdd() {
openingIsChild.value = false;
openEditModal({ id: null, name: '', path: '', customId: null, parentId: null, sortNo: null });
}
/* ---------------- 新增子模块 ---------------- */
function handleAddChild(parent: any) {
openingIsChild.value = true;
openEditModal({ id: null, name: '', path: '', customId: null, parentId: parent.id, sortNo: null });
}
/* ---------------- 编辑 ---------------- */
function handleEdit(item: any) {
openingIsChild.value = item.parentId != null;
openEditModal(item);
}
/* ---------------- 删除 ---------------- */
async function handleDelete(item: any) {
await MyConfirm({
msg: `确定删除模块「${item.name || item.id}」吗?`,
info: item._hasChildren ? "该模块下存在子模块,删除后子模块也将被删除!" : "删除后不可恢复!",
async onOk() {
try {
await Apis.AppSettings.deleteModule({ pathParams: { id: item.id } });
Message.success("删除成功!");
await fetchModuleList();
} catch (e: any) {
Message.error(e?.message || '删除失败');
}
},
});
}
/* ---------------- 提交(防抖) ---------------- */
const onSave = debounce(onRawSave, 500);
async function onRawSave() {
try {
const valid = await refFormData.value?.validate();
if (!valid) return;
} catch {
return;
}
submitting.value = true;
try {
if (currentId.value == null) {
await Apis.AppSettings.addModule({
data: {
appId: appId.value,
name: form.value.name,
path: form.value.path,
customId: form.value.customId == null ? '' : String(form.value.customId),
parentId: form.value.parentId ?? undefined,
sortNo: form.value.sortNo == null ? undefined : Number(form.value.sortNo),
},
});
} else {
await Apis.AppSettings.editModule({
pathParams: { id: currentId.value },
data: {
id: currentId.value,
appId: appId.value,
name: form.value.name,
path: form.value.path,
customId: form.value.customId == null ? '' : String(form.value.customId),
parentId: form.value.parentId ?? undefined,
sortNo: form.value.sortNo == null ? undefined : Number(form.value.sortNo),
},
});
}
Message.success("操作成功!");
editModal.value = false;
await fetchModuleList();
} catch (error) {
console.error(error);
} finally {
submitting.value = false;
}
}
function onReset() {
refFormData.value?.resetFields();
currentId.value = null;
editModal.value = false;
}
</script>
<style lang="scss" scoped>
.module-table {
:deep(.ivu-table-cell) {
padding-left: 12px;
padding-right: 12px;
}
}
.module-name-cell {
display: flex;
align-items: center;
.module-name-text {
margin-left: 6px;
}
}
</style>