init local code
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<Modal v-model="visible" title="子栏目排序" :width="520" :mask-closable="false">
|
||||
<Alert show-icon>拖动左侧手柄进行排序,保存后按此顺序在门户展示</Alert>
|
||||
<draggable class="sort-list" v-model="localChildren" 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-item-name">{{ element.name || element.id }}</span>
|
||||
<span class="sort-no">排序号: {{ element.sortNo ?? '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<div class="sort-empty" v-if="localChildren.length === 0">
|
||||
暂无子栏目
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="saving" @click="saveSort" :disabled="localChildren.length === 0">
|
||||
保存排序
|
||||
</Button>
|
||||
<Button @click="visible = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
/* UserBase GroupSort 平移:字段 sort→sortNo,接口换 categoryApi.sort */
|
||||
import { ref, watch } from "vue";
|
||||
import draggable from "vuedraggable";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { categoryApi } from "@/api/module/cms";
|
||||
import { useCmsStore } from "@/store";
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false })
|
||||
|
||||
const props = defineProps<{
|
||||
parentId: number | null
|
||||
parentName: string
|
||||
children: any[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
|
||||
const store = useCmsStore();
|
||||
const localChildren = ref<any[]>([])
|
||||
const saving = ref(false)
|
||||
const hasChanged = ref(false)
|
||||
|
||||
function resetLocal() {
|
||||
localChildren.value = (props.children || []).map((x) => ({ ...x }))
|
||||
hasChanged.value = false
|
||||
}
|
||||
|
||||
function onDragChange() {
|
||||
hasChanged.value = true
|
||||
}
|
||||
|
||||
async function saveSort() {
|
||||
if (!props.parentId && props.parentId !== 0) {
|
||||
Message.warning('请先选择一个栏目')
|
||||
return
|
||||
}
|
||||
if (!hasChanged.value) {
|
||||
Message.info('排序未发生变化')
|
||||
visible.value = false
|
||||
return
|
||||
}
|
||||
const ids = localChildren.value.map((x) => Number(x.id)).filter((x) => Number.isFinite(x))
|
||||
saving.value = true
|
||||
try {
|
||||
await categoryApi.sort({ siteId: store.siteId!, parentId: props.parentId, ids }).send()
|
||||
Message.success('保存成功')
|
||||
hasChanged.value = false
|
||||
emit('saved')
|
||||
visible.value = false
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (v) resetLocal()
|
||||
})
|
||||
</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-item-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort-loading,
|
||||
.sort-empty {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div class="group-tree-search-panel">
|
||||
<div v-if="searchVisible" class="search-bar mt-8 mb-8">
|
||||
<Input v-model="searchKeyword" :placeholder="searchPlaceholder" suffix="ios-search" search clearable
|
||||
class="search-input" @on-search="onSearch" @on-clear="onClear" />
|
||||
<Button v-if="showCloseButton" icon="md-close" @click="closeSearch" />
|
||||
</div>
|
||||
|
||||
<div class="tree-body">
|
||||
<MyTree v-if="!treeLoading" ref="refMyTree" :autoClick="autoClick" :treeData="treeData"
|
||||
:setting="treeSetting" :isCheck="checkable" @on-tree-click="treeLink"
|
||||
@on-tree-check="(node: any) => emit('on-tree-check', node)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
/* UserBase GroupTreeSearchPanel 平移:去掉组类型特殊选中逻辑,默认选首棵树首个节点 */
|
||||
import { ref, nextTick, useTemplateRef } from "vue";
|
||||
import { cloneDeep } from "lodash";
|
||||
import MyTree from '@/components/MyTree.vue'
|
||||
|
||||
const DEFAULT_CACHE_KEY = 'cms.category-tree.selected-node'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
treeApi: () => Promise<any[]>;
|
||||
autoClick?: boolean;
|
||||
autoSelect?: boolean;
|
||||
defaultSearchVisible?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
cacheKey?: string;
|
||||
checkable?: boolean;
|
||||
}>(), {
|
||||
autoClick: false,
|
||||
autoSelect: true,
|
||||
defaultSearchVisible: false,
|
||||
showCloseButton: false,
|
||||
searchPlaceholder: '输入栏目名称搜索',
|
||||
cacheKey: DEFAULT_CACHE_KEY,
|
||||
checkable: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'on-change': [treeNode: any]
|
||||
'on-tree-data': [tree: any[]]
|
||||
'on-tree-check': [treeNode: any]
|
||||
}>()
|
||||
|
||||
const curTreeNode = defineModel<any>('curTreeNode', { default: {} })
|
||||
|
||||
const refMyTree = useTemplateRef('refMyTree')
|
||||
const treeLoading = ref(false)
|
||||
const treeData = ref<any[]>([])
|
||||
const searchKeyword = ref('')
|
||||
const searchVisible = ref(props.defaultSearchVisible)
|
||||
|
||||
const treeSetting = ref({
|
||||
view: { dblClickExpand: false, showIcon: false },
|
||||
edit: { enable: false, showRemoveBtn: false, showRenameBtn: false, drag: false },
|
||||
check: { enable: props.checkable, chkboxType: { Y: '', N: '' } },
|
||||
callback: {},
|
||||
})
|
||||
|
||||
function formatTree(list: any[], depth = 1): any[] {
|
||||
return (list || []).map((node) => {
|
||||
const children = node.children;
|
||||
const hasChildren = Array.isArray(children) && children.length > 0;
|
||||
const formatted: any = { ...node, isParent: hasChildren };
|
||||
if (depth === 1) formatted.open = true;
|
||||
if (hasChildren) {
|
||||
formatted.children = formatTree(children, depth + 1);
|
||||
} else {
|
||||
delete formatted.children;
|
||||
}
|
||||
return formatted;
|
||||
});
|
||||
}
|
||||
|
||||
function findNodeById(list: any[], id?: number | null): any {
|
||||
if (id == null) return null;
|
||||
for (const node of list || []) {
|
||||
if (Number(node.id) === Number(id)) return node;
|
||||
const child = findNodeById(node.children || [], id);
|
||||
if (child) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCachedSelectedNodeId() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(props.cacheKey);
|
||||
if (!raw) return null;
|
||||
const cached = JSON.parse(raw);
|
||||
const id = Number(cached?.id);
|
||||
return Number.isFinite(id) ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cacheSelectedNode(treeNode: any) {
|
||||
if (!treeNode?.id) return;
|
||||
sessionStorage.setItem(props.cacheKey, JSON.stringify({
|
||||
id: Number(treeNode.id),
|
||||
name: treeNode.name,
|
||||
type: treeNode.type,
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveInitialSelectId(list: any[], preferredId?: number | null) {
|
||||
const preferred = findNodeById(list, preferredId);
|
||||
if (preferred) return preferred.id;
|
||||
|
||||
const cached = findNodeById(list, getCachedSelectedNodeId());
|
||||
if (cached) return cached.id;
|
||||
|
||||
return list[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async function loadTree(preferredId?: number | null) {
|
||||
treeLoading.value = true;
|
||||
try {
|
||||
const tree = await props.treeApi();
|
||||
treeData.value = formatTree(tree || []);
|
||||
emit('on-tree-data', cloneDeep(treeData.value));
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message || '加载栏目树失败');
|
||||
} finally {
|
||||
treeLoading.value = false;
|
||||
}
|
||||
|
||||
if (!treeData.value.length) return;
|
||||
|
||||
await nextTick();
|
||||
// 默认自动选中首个可用节点(触发 on-change)
|
||||
if (props.autoSelect) {
|
||||
const selectId = resolveInitialSelectId(treeData.value, preferredId);
|
||||
if (selectId != null) {
|
||||
refMyTree.value?.reloadTree(selectId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTree(selectId?: number) {
|
||||
await loadTree(selectId);
|
||||
}
|
||||
|
||||
function treeLink(treeNode: any) {
|
||||
const copyNode = cloneDeep(treeNode)
|
||||
copyNode.children = [];
|
||||
curTreeNode.value = copyNode;
|
||||
cacheSelectedNode(copyNode);
|
||||
emit('on-change', copyNode)
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
refMyTree.value?.search(searchKeyword.value);
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
searchKeyword.value = '';
|
||||
refMyTree.value?.search('');
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
searchVisible.value = true;
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
searchVisible.value = false;
|
||||
onClear();
|
||||
}
|
||||
|
||||
function reloadTree(id?: number) {
|
||||
refMyTree.value?.reloadTree(id);
|
||||
}
|
||||
|
||||
function selectNode(id: number, isClick = true) {
|
||||
refMyTree.value?.selectNode(id, isClick);
|
||||
}
|
||||
|
||||
function removeNode(id: number) {
|
||||
refMyTree.value?.removeNode(id);
|
||||
}
|
||||
|
||||
function renameNode(id: number, name: string) {
|
||||
refMyTree.value?.renameNode(id, name);
|
||||
}
|
||||
|
||||
function getNodeById(id: number) {
|
||||
return refMyTree.value?.getNodeById(id);
|
||||
}
|
||||
|
||||
function addNote(pid: number, id: number, name: string, isParent?: boolean, children?: any[]) {
|
||||
refMyTree.value?.addNote(pid, id, name, isParent, children);
|
||||
}
|
||||
|
||||
function openTree(open: boolean) {
|
||||
refMyTree.value?.openTree(open);
|
||||
}
|
||||
|
||||
function searchByKeyword(keyword: string) {
|
||||
searchKeyword.value = keyword;
|
||||
onSearch();
|
||||
}
|
||||
|
||||
function clearByKeyword() {
|
||||
searchKeyword.value = '';
|
||||
onClear();
|
||||
}
|
||||
|
||||
loadTree()
|
||||
|
||||
defineExpose({
|
||||
openSearch,
|
||||
closeSearch,
|
||||
onSearch,
|
||||
onClear,
|
||||
searchByKeyword,
|
||||
clearByKeyword,
|
||||
loadTree,
|
||||
refreshTree,
|
||||
reloadTree,
|
||||
selectNode,
|
||||
removeNode,
|
||||
renameNode,
|
||||
getNodeById,
|
||||
addNote,
|
||||
openTree,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.group-tree-search-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tree-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tree-body :deep(.ztree) {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<Modal v-model="isModal" @on-visible-change="showChange" :title="title">
|
||||
<Alert type="warning" class="mb-1" show-icon>
|
||||
<template #icon>
|
||||
<Icon type="ios-bulb-outline" :class="{ 'mt-5': !info }" slot="icon" :size="28"></Icon>
|
||||
</template>
|
||||
<div v-if="msg" :class="['message-box', info ? '' : 'mt-10 mb-10 ml-10']">{{ msg }}</div>
|
||||
<template #desc v-if="info" slot="desc">{{ info }}</template>
|
||||
</Alert>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="okLoading" @click="onOkClick">{{ confirmText }}</Button>
|
||||
<Button :loading="cancleLoading" @click="onCancleClick">{{ closeText }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { Modal, Button, Alert, Icon } from "view-ui-plus";
|
||||
|
||||
const isModal = ref(false),
|
||||
okLoading = ref(false),
|
||||
cancleLoading = ref(false);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
msg: string;
|
||||
info?: string;
|
||||
isLoading?: boolean;
|
||||
confirmText?: string;
|
||||
closeText?: string;
|
||||
confirmFn: any;
|
||||
closeFn: any;
|
||||
destroy: Function;
|
||||
}>(),
|
||||
{
|
||||
title: "提示",
|
||||
isLoading: true,
|
||||
confirmText: "确认",
|
||||
closeText: "取消",
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(function () {
|
||||
setTimeout(() => {
|
||||
isModal.value = true;
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const onOkClick = async () => {
|
||||
if (!!props.isLoading) okLoading.value = true;
|
||||
const { onOk, reslove } = props.confirmFn;
|
||||
await Promise.resolve(onOk());
|
||||
isModal.value = false;
|
||||
okLoading.value = false;
|
||||
reslove(true);
|
||||
};
|
||||
|
||||
const onCancleClick = async () => {
|
||||
if (!!props.isLoading) cancleLoading.value = true;
|
||||
const { onCancel, reslove } = props.closeFn;
|
||||
if (typeof onCancel !== "undefined") await Promise.resolve(onCancel());
|
||||
cancleLoading.value = false;
|
||||
isModal.value = false;
|
||||
reslove(false);
|
||||
};
|
||||
|
||||
const showChange = (state: boolean) => {
|
||||
if (!state) {
|
||||
props.destroy();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MyConfirm } from "@/types/Basics";
|
||||
import confirm from "./Confirm.vue";
|
||||
|
||||
import { createApp } from "vue";
|
||||
export default ({ title, msg, info, confirmText, closeText, isLoading, onOk, onCancel }: MyConfirm) => {
|
||||
return new Promise<boolean>((reslove, reject) => {
|
||||
const instance = createApp(confirm, {
|
||||
title,
|
||||
msg,
|
||||
info,
|
||||
confirmText,
|
||||
closeText,
|
||||
isLoading,
|
||||
confirmFn: {
|
||||
onOk,
|
||||
reslove,
|
||||
},
|
||||
closeFn: {
|
||||
onCancel,
|
||||
reslove,
|
||||
},
|
||||
destroy: () => {
|
||||
closeUnmount();
|
||||
},
|
||||
});
|
||||
|
||||
// 创建一个节点
|
||||
const vnode = document.createElement("div");
|
||||
// 添加到body元素上
|
||||
document.body.appendChild(vnode);
|
||||
// 实例挂载到节点上
|
||||
instance.mount(vnode);
|
||||
// 关闭状态
|
||||
// 注册实例并移除节点
|
||||
function closeUnmount() {
|
||||
setTimeout(() => {
|
||||
instance.unmount();
|
||||
document.body.removeChild(vnode);
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div>
|
||||
<div :id="domId" :ref="domId" class="ztree"></div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
/* UserBase MyTree 原样平移(jQuery zTree 封装);全局 _ 改为 npm lodash 引入 */
|
||||
import _ from "lodash";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
treeData: {
|
||||
type: Object,
|
||||
},
|
||||
setting: {
|
||||
type: Object,
|
||||
default: {},
|
||||
},
|
||||
autoClick: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isCheck: {
|
||||
typeof: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
domId: "",
|
||||
treeSetting: _.cloneDeep(this.setting),
|
||||
treeObj: null,
|
||||
highlightNodes: [],
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.$watch("treeData", val => {
|
||||
if (val) {
|
||||
this.$nextTick(() => {
|
||||
this.initTree(this.treeData, this.domId);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
mounted() {
|
||||
this.domId = this.uuid();
|
||||
if (this.treeData) {
|
||||
this.$nextTick(() => {
|
||||
this.initTree(this.treeData, this.domId);
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
uuid() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
var r = (Math.random() * 16) | 0,
|
||||
v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
},
|
||||
reloadTree(nodeId) {
|
||||
this.initTree(this.treeData, this.domId);
|
||||
if (nodeId != null) {
|
||||
this.$nextTick(() => {
|
||||
this.selectNode(nodeId, true); // 第二个参数 true 会 emit on-tree-click
|
||||
});
|
||||
}
|
||||
},
|
||||
initTree(_data, _domId) {
|
||||
const _this = this;
|
||||
_this.treeSetting.callback.onClick = (event, treeId, treeNode, clickFlag) => {
|
||||
if (_this.isCheck) {
|
||||
var treeObj = $.fn.zTree.getZTreeObj(treeId);
|
||||
// 第4参 callbackFlag=true:点击文字切换勾选时同步触发 onCheck,与点击勾选框行为一致
|
||||
treeObj.checkNode(treeNode, !treeNode.checked, true, true);
|
||||
}
|
||||
_this.$emit("on-tree-click", treeNode);
|
||||
};
|
||||
|
||||
_this.treeSetting.callback.beforeDrop = (treeId, treeNodes, targetNode, moveType) => {
|
||||
if (moveType == "inner") {
|
||||
// 移除限制,允许叶子节点接受子节点
|
||||
} else {
|
||||
if (targetNode.pid === 0) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
_this.treeSetting.callback.beforeDrag = (treeId, treeNodes) => {
|
||||
return treeNodes[0].drag !== false;
|
||||
};
|
||||
|
||||
_this.treeSetting.callback.beforeClick = (treeId, treeNode, clickFlag) => {
|
||||
return !(treeNode.click === false);
|
||||
};
|
||||
|
||||
_this.treeSetting.callback.onDrop = (event, treeId, treeNodes, targetNode, moveType) => {
|
||||
_this.$emit("on-tree-drag", { treeNodes, targetNode, moveType });
|
||||
};
|
||||
|
||||
_this.treeSetting.callback.onCheck = (event, treeId, treeNode) => {
|
||||
_this.$emit("on-tree-check", treeNode);
|
||||
};
|
||||
|
||||
_this.treeObj = $.fn.zTree.init($("#" + _domId), _this.treeSetting, _data);
|
||||
if (_this.autoClick) {
|
||||
if (Array.isArray(this.treeData)) {
|
||||
let id = this.treeData[0] && this.treeData[0].id;
|
||||
if (id != null) _this.selectNode(id);
|
||||
} else if (this.treeData && this.treeData.id != null) {
|
||||
let id = this.treeData.id;
|
||||
_this.selectNode(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
selectNode(id, isClick = true) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||
if (node == null) return false;
|
||||
treeObj.selectNode(node);
|
||||
treeObj.expandNode(node, true, false, false);
|
||||
treeObj.selectNode(node);
|
||||
if (isClick) this.$emit("on-tree-click", node);
|
||||
},
|
||||
openTree(open) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
treeObj.expandAll(open);
|
||||
},
|
||||
removeNode(id) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||
if (node == null) return;
|
||||
var pnode = node.getParentNode();
|
||||
treeObj.removeNode(node);
|
||||
if (pnode) {
|
||||
pnode.isParent = true;
|
||||
treeObj.updateNode(pnode);
|
||||
this.selectNode(pnode.id);
|
||||
}
|
||||
},
|
||||
getNodeById(id) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||
return node;
|
||||
},
|
||||
getCheckedNodes() {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
if (!treeObj) return [];
|
||||
return treeObj.getCheckedNodes(true);
|
||||
},
|
||||
// 仅更新节点勾选状态,不级联、不触发 onCheck 回调
|
||||
checkNodeById(id, checked) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
if (!treeObj) return;
|
||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||
if (node) treeObj.checkNode(node, !!checked, false);
|
||||
},
|
||||
renameNode(id, name) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var node = treeObj.getNodesByFilter(node => node.id == id, true);
|
||||
if (node == null) return;
|
||||
node.name = name;
|
||||
treeObj.updateNode(node);
|
||||
},
|
||||
addNote(pid, id, name, isParent, children) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
var pnode = treeObj.getNodesByFilter(node => node.id == pid, true);
|
||||
if (pnode == null) return;
|
||||
var newNode = { pid, id, name, isParent };
|
||||
if (children && children.length) newNode.children = children;
|
||||
treeObj.addNodes(pnode, -1, newNode);
|
||||
},
|
||||
setHighlight(node, on = true) {
|
||||
const a = $('#' + node.tId + '_a');
|
||||
if (on) a.addClass('ztree-search-highlight');
|
||||
else a.removeClass('ztree-search-highlight');
|
||||
},
|
||||
clearHighlight() {
|
||||
(this.highlightNodes || []).forEach(n => this.setHighlight(n, false));
|
||||
this.highlightNodes = [];
|
||||
},
|
||||
search(keyword) {
|
||||
const _domId = this.domId;
|
||||
var treeObj = $.fn.zTree.getZTreeObj(_domId);
|
||||
if (!treeObj) return;
|
||||
this.clearHighlight();
|
||||
const kw = (keyword || '').trim().toLowerCase();
|
||||
if (!kw) return;
|
||||
const matchNodes = treeObj.getNodesByFilter(
|
||||
(node) => node.name && node.name.toLowerCase().includes(kw),
|
||||
false
|
||||
) || [];
|
||||
if (!matchNodes.length) {
|
||||
if (this.$Message && this.$Message.info) this.$Message.info('未搜索到匹配栏目');
|
||||
return;
|
||||
}
|
||||
matchNodes.forEach(n => {
|
||||
let p = n;
|
||||
while (p) {
|
||||
treeObj.expandNode(p, true, false, false);
|
||||
p = p.getParentNode();
|
||||
}
|
||||
});
|
||||
this.highlightNodes = matchNodes;
|
||||
matchNodes.forEach(n => this.setHighlight(n, true));
|
||||
this.selectNode(matchNodes[0].id, true);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.ztree-search-highlight {
|
||||
background-color: #ff6600 !important;
|
||||
}
|
||||
|
||||
.ztree li a.curSelectedNode {
|
||||
background-color: #ffe4bb !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,443 @@
|
||||
<script setup lang="ts">
|
||||
/* 学校专属管理壳(docs/09 §3.3,由 layout.vue 改造,二级壳 /sch/:schCode):
|
||||
* header 左侧「←学校列表」+ 校名徽标/快速切换下拉;面包屑首段=校名;
|
||||
* 面包屑栏右侧「站点切换(校内主站+院系子站)+ 预览门户」承接院系子站维度。
|
||||
* 菜单/高亮/子页面面包屑逻辑与原 layout 一致,布局数据来自 store.session。 */
|
||||
import { ref, computed, watch, onBeforeMount } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { resolveMenuPage } from "@/utils/menuRoute";
|
||||
import { useCmsStore } from "@/store";
|
||||
import config from "@/assets/config/host.config";
|
||||
import { portalBase } from "@/api/module/cms";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useCmsStore();
|
||||
|
||||
const isPanel = ref(false);
|
||||
const currPath = ref<string>("");
|
||||
const navPath = ref<any[]>([]);
|
||||
|
||||
const baseHost = computed(() => config.baseHost);
|
||||
const ssoHost = computed(() => config.ssoHost);
|
||||
const appName = computed(() => "云海CMS");
|
||||
const userInfo = computed(() => ({
|
||||
name: store.session.baseUserName || "管理员",
|
||||
avatar: store.session.baseUserAvatar,
|
||||
}));
|
||||
const isAlone = computed(() => store.session.isAlone);
|
||||
const modList = computed(() => store.session.modList || []);
|
||||
|
||||
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
|
||||
|
||||
/** 当前学校 + 校内站点(主站在前,切换下拉用) */
|
||||
const school = computed(() => store.currentSchool);
|
||||
const schoolSites = computed(() => store.schoolSites);
|
||||
/** 快速切换学校候选 = 可管理顶层站点(主站) */
|
||||
const schoolOptions = computed(() =>
|
||||
((store.user?.sites || []) as any[]).filter((s: any) => !s.parentSiteId)
|
||||
);
|
||||
|
||||
const flaten = (tree: any[], pid = 0): any[] => {
|
||||
const arr: any[] = [];
|
||||
tree.forEach((element: any) => {
|
||||
const { id, name, modId } = element;
|
||||
// 平台配置的 path 格式不可控,统一解析为内部路由 path(名称/modId 兜底)。
|
||||
// 解析结果回写原对象:模板高亮(citem.page == currPath)与 linkMenu 消费的是
|
||||
// 原始 children,旧扁平路由下 raw page 与解析结果恰好相等,学校前缀后必须显式回写
|
||||
const resolved = resolveMenuPage(element.page, element.modId ?? element.id, element.name);
|
||||
if (resolved.path) {
|
||||
element.page = resolved.path;
|
||||
}
|
||||
element.external = resolved.external;
|
||||
arr.push({ id, name, modId, page: resolved.path, external: resolved.external, pid });
|
||||
if (element.children && element.children.length) {
|
||||
arr.push(...flaten(element.children, id));
|
||||
}
|
||||
});
|
||||
return arr;
|
||||
};
|
||||
|
||||
const menuArr = computed(() => flaten(modList.value));
|
||||
|
||||
const routeChange = (to: any) => {
|
||||
const meta: any = to.meta || {};
|
||||
const { modId, title, subOf } = meta;
|
||||
const navArr: any[] = [];
|
||||
|
||||
// 侧边栏子菜单组自动展开:当前高亮落在某组子项时展开该组(改善直达体验)
|
||||
const expandGroupOf = (page: string) => {
|
||||
if (!page) return;
|
||||
modList.value.forEach((m: any) => {
|
||||
if (m.children?.some((c: any) => c.page === page)) {
|
||||
m.childState = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 列表子页面(docs/09 §3.2,如新增/编辑文章):面包屑挂到所属列表下、
|
||||
// 侧边栏高亮锁定列表项,优先级高于本页菜单匹配。
|
||||
// subOf = 所属列表的业务子路径(/article);菜单解析后带学校前缀,按后缀匹配,modId 兜底
|
||||
if (subOf) {
|
||||
const parent =
|
||||
menuArr.value.find((x: any) => x.page && (x.page === subOf || x.page.endsWith(subOf))) ||
|
||||
menuArr.value.find((x: any) => x.modId === subOf);
|
||||
if (parent) {
|
||||
currPath.value = parent.page || "";
|
||||
expandGroupOf(currPath.value);
|
||||
if (parent.pid) {
|
||||
const gp = menuArr.value.find((x: any) => x.id === parent.pid);
|
||||
if (gp) navArr.push({ name: gp.name });
|
||||
}
|
||||
navArr.push({ name: parent.name, page: parent.page });
|
||||
navArr.push({ name: title });
|
||||
navPath.value = navArr;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
|
||||
// 匹配不到再退回 modId 对照
|
||||
const find: any =
|
||||
menuArr.value.find((x: any) => x.page && x.page === to.path) ||
|
||||
menuArr.value.find((x: any) => x.modId === modId);
|
||||
if (find) {
|
||||
currPath.value = find.page || "";
|
||||
expandGroupOf(currPath.value);
|
||||
if (find.pid) {
|
||||
const pfind = menuArr.value.find((x: any) => x.id === find.pid);
|
||||
if (pfind) navArr.push({ name: pfind.name });
|
||||
}
|
||||
navArr.push({ name: find.name || title, page: find.page });
|
||||
navPath.value = navArr;
|
||||
return;
|
||||
}
|
||||
|
||||
if (modId != null) {
|
||||
const r = router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
|
||||
if (r) {
|
||||
currPath.value = r.path;
|
||||
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
currPath.value = "";
|
||||
navPath.value = [];
|
||||
};
|
||||
|
||||
const linkMenu = (item: any) => {
|
||||
const page: string = item?.page || "";
|
||||
if (!page) {
|
||||
// 解析不出有效页面:显式提示,而不是静默不动或被重定向吞到默认页
|
||||
Message.warning(`菜单「${item?.name || "未命名"}」未配置有效页面路径,请检查基础平台模块设置`);
|
||||
return;
|
||||
}
|
||||
// 平台配置的外部链接模块:整页跳转
|
||||
if (item?.external) {
|
||||
window.location.href = page;
|
||||
return;
|
||||
}
|
||||
if (page === route.path) {
|
||||
router.go(0);
|
||||
} else {
|
||||
router.push(page);
|
||||
}
|
||||
};
|
||||
|
||||
const linkUserInfo = () => {
|
||||
window.location.href = baseHost.value + "/v/userinfo";
|
||||
};
|
||||
const logout = () => {
|
||||
let url = window.location.href;
|
||||
url = "/cas/logout?service=" + url;
|
||||
window.location.href = ssoHost.value + url;
|
||||
};
|
||||
const childChange = (index: number) => {
|
||||
const arr = modList.value || [];
|
||||
if (arr[index]) {
|
||||
arr[index].childState = !arr[index].childState;
|
||||
}
|
||||
};
|
||||
|
||||
/** 根据菜单项映射图标:优先按解析后的 page 判断(平台模块 id 不可依赖),
|
||||
* 兜底保留 modId 对照 */
|
||||
const getMenuIcon = (item?: any): string => {
|
||||
const page: string = item?.page || "";
|
||||
if (page === "/article" || page.startsWith("/article")) return "ios-document";
|
||||
if (page === "/category") return "ios-list-box";
|
||||
if (page === "/banner") return "ios-images";
|
||||
if (page === "/link") return "ios-link";
|
||||
if (page === "/attachment") return "ios-folder-open";
|
||||
if (page === "/site") return "ios-globe";
|
||||
switch (item?.modId ?? item?.id) {
|
||||
case 1: return "ios-document";
|
||||
case 2: return "ios-list-box";
|
||||
case 3: return "ios-images";
|
||||
case 4: return "ios-link";
|
||||
case 5: return "ios-folder-open";
|
||||
case 6: return "ios-globe";
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
/* ===== 学校上下文(docs/09 §3.3)===== */
|
||||
function goWorkbench() {
|
||||
router.push("/workbench");
|
||||
}
|
||||
|
||||
/** 快速切换学校:守卫负责恢复上下文,进入后落在文章列表 */
|
||||
function onSchoolChange(code: string) {
|
||||
if (!code || code === store.schoolCode) return;
|
||||
router.push(`/sch/${code}/article`);
|
||||
}
|
||||
|
||||
function onSiteChange(siteId: number) {
|
||||
const site = schoolSites.value.find((s: any) => s.id === siteId);
|
||||
if (site) {
|
||||
store.setCurrentSite({ id: site.id, code: site.code, name: site.name });
|
||||
}
|
||||
}
|
||||
|
||||
function openPortal() {
|
||||
if (!store.currentSite) return;
|
||||
if (config.isMock) {
|
||||
// mock 模式:SPA 内部门户预览(免依赖后端 Thymeleaf 门户)
|
||||
router.push(`/sch/${store.schoolCode}/portal-preview`);
|
||||
} else {
|
||||
window.open(`${portalBase(store.currentSite.code)}/`, "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动引导期守卫放行的深链:user 数据就绪后兜底校验/恢复学校上下文 */
|
||||
watch(
|
||||
() => store.user,
|
||||
u => {
|
||||
if (!u) return;
|
||||
const code = route.params.schCode as string;
|
||||
if (route.path.startsWith("/sch/") && !school.value) {
|
||||
const sites = u.sites || [];
|
||||
const main = sites.find((s: any) => s.code === code);
|
||||
if (!main) {
|
||||
router.replace("/workbench");
|
||||
return;
|
||||
}
|
||||
store.enterSchool(
|
||||
{
|
||||
schId: main.schId,
|
||||
schName: main.schName,
|
||||
mainSiteId: main.id,
|
||||
mainSiteCode: main.code,
|
||||
mainSiteName: main.name,
|
||||
},
|
||||
sites.filter((s: any) => s.id === main.id || s.parentSiteId === main.id)
|
||||
);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeMount(() => {
|
||||
routeChange(route);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => routeChange(route)
|
||||
);
|
||||
|
||||
// 深链启动期:学校上下文/菜单晚于首次 routeChange 到达,就绪后重算面包屑与高亮
|
||||
watch(
|
||||
() => [store.currentSchool?.mainSiteCode, store.session.modList.length],
|
||||
() => routeChange(route)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-wrap">
|
||||
<header class="header-container fix">
|
||||
<div class="fl align-middle icon-box">
|
||||
<div title="常用应用" :class="['icon-btn', isPanel ? 'active' : '']" @click="isPanel = !isPanel">
|
||||
<i class="iconfont icon-apps"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<a class="fl align-middle back-workbench" href="javascript:void(0);" title="返回学校列表" @click="goWorkbench">
|
||||
<Icon type="ios-arrow-back" />
|
||||
学校列表
|
||||
</a>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<h4 class="fl align-middle">{{ appName }}</h4>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<!-- 校名徽标 + 快速切换(当前学校常驻 header) -->
|
||||
<div class="school-badge fl align-middle" v-if="school">
|
||||
<Icon type="ios-school" :size="16" />
|
||||
<Select
|
||||
size="small"
|
||||
:model-value="school.mainSiteCode"
|
||||
class="school-select"
|
||||
transfer
|
||||
@on-change="onSchoolChange"
|
||||
>
|
||||
<Option v-for="s in schoolOptions" :key="s.id" :value="s.code">
|
||||
{{ s.schName || s.name }}
|
||||
</Option>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="header-aside fr">
|
||||
<img :src="userInfo.avatar" v-if="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
|
||||
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
|
||||
{{ userInfo.name }}
|
||||
</a>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<a href="javascript:;" @click="logout" class="logout fl align-middle" title="退出登陆">
|
||||
<i class="iconfont icon-logout"></i>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="left-menu" v-if="!isAlone">
|
||||
<div class="menu-top-box"></div>
|
||||
<ul>
|
||||
<template v-for="(item, index) in modList" :key="'menu_li_' + index">
|
||||
<template v-if="item.children && item.children.length > 0">
|
||||
<li>
|
||||
<a href="javascript:;" @click="childChange(index)">
|
||||
<i :class="item.childState ? 'arrow-down' : 'arrow-up'"></i>
|
||||
<Icon v-if="getMenuIcon(item)" :type="getMenuIcon(item)" />
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</li>
|
||||
<transition name="fade_child_ul">
|
||||
<div class="child-item-wrap" v-show="item.childState">
|
||||
<li
|
||||
:class="citem.page == currPath ? 'active child-item' : 'child-item'"
|
||||
v-for="citem in item.children"
|
||||
:key="citem.id"
|
||||
>
|
||||
<a href="javascript:;" @click="linkMenu(citem)">
|
||||
{{ citem.name }}
|
||||
</a>
|
||||
</li>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
<li v-else :class="item.page == currPath ? 'active' : ''">
|
||||
<a href="javascript:;" @click="linkMenu(item)">
|
||||
<Icon v-if="getMenuIcon(item)" :type="getMenuIcon(item)" />
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="clear-box"></div>
|
||||
</div>
|
||||
|
||||
<div class="path-nav" :style="pathNavStyle">
|
||||
<Breadcrumb separator=">">
|
||||
<!-- 面包屑首段 = 学校上下文(常驻) -->
|
||||
<BreadcrumbItem v-if="school">
|
||||
<Icon type="ios-school" />
|
||||
{{ school.schName || school.mainSiteName }}
|
||||
</BreadcrumbItem>
|
||||
<template v-for="(item, index) in navPath" :key="index">
|
||||
<BreadcrumbItem v-if="index != navPath.length - 1" :to="item.page">
|
||||
{{ item.name }}
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem v-else>{{ item.name }}</BreadcrumbItem>
|
||||
</template>
|
||||
</Breadcrumb>
|
||||
<!-- 站点上下文(校内:主站 + 院系子站;切换后各页面即时跟随) -->
|
||||
<div class="cms-site-bar fr">
|
||||
<span class="site-label">站点:</span>
|
||||
<Select
|
||||
size="small"
|
||||
:model-value="store.currentSite?.id"
|
||||
:placeholder="schoolSites.length ? '选择站点' : '暂无可管理站点'"
|
||||
style="width: 200px"
|
||||
transfer
|
||||
@on-change="onSiteChange"
|
||||
>
|
||||
<Option v-for="opt in schoolSites" :key="opt.id" :value="opt.id">
|
||||
{{ opt.name }}{{ opt.parentSiteId ? "(院系)" : "" }}
|
||||
</Option>
|
||||
</Select>
|
||||
<Button size="small" type="primary" ghost style="margin-left: 10px" @click="openPortal">
|
||||
<Icon type="ios-eye" />
|
||||
预览门户
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade_apps">
|
||||
<div class="app-panel" v-if="isPanel">
|
||||
<div class="tips-box"></div>
|
||||
<ul class="fix">
|
||||
<li class="fl" v-for="(item, index) in []" :key="index">
|
||||
<a :href="item.url" target="_blank" rel="noopener noreferrer">
|
||||
<div class="icon-box">
|
||||
<img :src="item.icon" alt="" />
|
||||
</div>
|
||||
<p class="text-ellipsis">{{ item.name }}</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="link-panel">
|
||||
<a :href="baseHost + '/BschHome/front/index.html#/apps'" target="_blank" rel="noopener noreferrer">
|
||||
全部应用
|
||||
<i>→</i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="base-wrap-main" :class="{ 'alone-page': isAlone }">
|
||||
<div class="main-wrapper">
|
||||
<router-view v-if="store.currentSite" />
|
||||
<div v-else class="no-site-tip">当前学校暂无可管理站点,请联系平台管理员在学校工作台添加。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 返回学校列表 + 校名徽标:header 常驻学校上下文(docs/09 §3.3) */
|
||||
.back-workbench {
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
.school-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
.school-select {
|
||||
width: 180px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
/* 站点切换条:挂在面包屑条右侧,沿用 path-nav 白底视觉 */
|
||||
.cms-site-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
margin-right: 24px;
|
||||
|
||||
.site-label {
|
||||
color: #777;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
.no-site-tip {
|
||||
height: 60vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, withDefaults } from "vue";
|
||||
|
||||
interface Props {
|
||||
prefix?: string;
|
||||
name?: string;
|
||||
color?: string;
|
||||
hoverColor?: string;
|
||||
size?: string | number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
prefix: "icon",
|
||||
name: "",
|
||||
color: "#000",
|
||||
});
|
||||
|
||||
const isHover = ref(false);
|
||||
const symbolId = computed(() => `#${props.prefix}-${props.name}`);
|
||||
const svgStyle = computed(() => {
|
||||
if (!!props.size) return { width: props.size + "px", height: props.size + "px" };
|
||||
else return null;
|
||||
});
|
||||
|
||||
const compColor = computed(() => {
|
||||
if (!props.hoverColor) return props.color;
|
||||
else {
|
||||
return !!isHover.value ? props.hoverColor : props.color;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
@mouseover="isHover = true"
|
||||
@mouseout="isHover = false"
|
||||
aria-hidden="true"
|
||||
:color="compColor"
|
||||
:style="svgStyle"
|
||||
>
|
||||
<use :xlink:href="symbolId" :fill="compColor" :color="compColor" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { ThemeVo } from "@/types/cms";
|
||||
import { effectiveValue, invalidParamKeys, FormModelValue } from "@/utils/themeParamForm";
|
||||
|
||||
/**
|
||||
* 主题参数微调表单(方案 §6.3):按 theme.json params schema 动态生成。
|
||||
* color→ColorPicker、select→Select、image→Input(手填 /cms/upload/ 路径,走附件上传后回填)、
|
||||
* text→Input。父级 v-model 绑定 FormModelValue(声明过的 key,值已兜底为默认)。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
theme?: ThemeVo;
|
||||
modelValue: FormModelValue;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", v: FormModelValue): void;
|
||||
}>();
|
||||
|
||||
const themeName = computed(() => props.theme?.title || props.theme?.name || "");
|
||||
const params = computed(() => props.theme?.params || []);
|
||||
const badKeys = computed(() =>
|
||||
props.theme ? invalidParamKeys(props.theme, props.modelValue) : []
|
||||
);
|
||||
|
||||
function setValue(key: string, value: string) {
|
||||
emit("update:modelValue", { ...props.modelValue, [key]: value });
|
||||
}
|
||||
|
||||
function displayValue(key: string): string {
|
||||
return effectiveValue(props.theme?.params.find(p => p.key === key), props.modelValue);
|
||||
}
|
||||
|
||||
function isBad(key: string): boolean {
|
||||
return badKeys.value.includes(key);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!theme" class="empty-tip">请先选择皮肤包</div>
|
||||
<Form v-else :label-width="90" class="theme-param-form">
|
||||
<div class="param-head">
|
||||
参数微调 · {{ themeName }}
|
||||
<span v-if="badKeys.length" class="param-warn">
|
||||
有 {{ badKeys.length }} 个非法值未生效(将回退皮肤默认)
|
||||
</span>
|
||||
</div>
|
||||
<FormItem v-for="p in params" :key="p.key" :label="p.label">
|
||||
<!-- color:色板 -->
|
||||
<ColorPicker
|
||||
v-if="p.type === 'color'"
|
||||
:model-value="displayValue(p.key)"
|
||||
recommend
|
||||
@on-change="(c: string) => setValue(p.key, c)"
|
||||
/>
|
||||
<!-- select:下拉 -->
|
||||
<Select
|
||||
v-else-if="p.type === 'select'"
|
||||
:model-value="displayValue(p.key)"
|
||||
style="width: 260px"
|
||||
@on-change="(v: string) => setValue(p.key, v)"
|
||||
>
|
||||
<Option v-for="o in p.options" :key="o.value" :value="o.value">{{ o.label }}</Option>
|
||||
</Select>
|
||||
<!-- image:附件路径(上传后回填 /cms/upload/**) -->
|
||||
<Input
|
||||
v-else-if="p.type === 'image'"
|
||||
:model-value="displayValue(p.key)"
|
||||
placeholder="/cms/upload/cms/... (仅接受 /cms/upload/** 路径)"
|
||||
style="width: 320px"
|
||||
@on-change="(v: string) => setValue(p.key, v)"
|
||||
/>
|
||||
<!-- text:短文本 -->
|
||||
<Input
|
||||
v-else
|
||||
:model-value="displayValue(p.key)"
|
||||
:maxlength="256"
|
||||
style="width: 320px"
|
||||
@on-change="(v: string) => setValue(p.key, v)"
|
||||
/>
|
||||
<span v-if="isBad(p.key)" class="bad-mark">当前值非法({{ p.type }} 类型校验失败)</span>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.empty-tip {
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.param-head {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.param-warn {
|
||||
color: #ed4014;
|
||||
font-weight: 400;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.bad-mark {
|
||||
color: #ed4014;
|
||||
font-size: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { ThemeVo } from "@/types/cms";
|
||||
|
||||
/**
|
||||
* 主题卡片单选(方案 §6.3):建站向导①与站点编辑共用。
|
||||
* 展示 screenshot + 名称/描述;选中高亮;卡片布局在父级控制宽度。
|
||||
*/
|
||||
defineProps<{
|
||||
themes: ThemeVo[];
|
||||
/** 当前选中主题名 */
|
||||
modelValue?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", name: string): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-picker">
|
||||
<div
|
||||
v-for="t in themes"
|
||||
:key="t.name"
|
||||
class="theme-card"
|
||||
:class="{ selected: modelValue === t.name }"
|
||||
@click="emit('update:modelValue', t.name)"
|
||||
>
|
||||
<img class="theme-shot" :src="t.screenshotUrl" :alt="t.title" loading="lazy" />
|
||||
<div class="theme-info">
|
||||
<div class="theme-title">
|
||||
{{ t.title }}
|
||||
<span class="theme-name">{{ t.name }}</span>
|
||||
</div>
|
||||
<p class="theme-desc">{{ t.description }}</p>
|
||||
</div>
|
||||
<i v-if="modelValue === t.name" class="theme-check">✓</i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.theme-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.theme-card {
|
||||
position: relative;
|
||||
border: 2px solid #e5e6eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
&:hover {
|
||||
border-color: #a9c6f5;
|
||||
}
|
||||
&.selected {
|
||||
border-color: #2d8cf0;
|
||||
box-shadow: 0 0 0 2px rgba(45, 140, 240, 0.15);
|
||||
}
|
||||
}
|
||||
.theme-shot {
|
||||
width: 100%;
|
||||
height: 118px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
.theme-info {
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
.theme-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
.theme-name {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #999;
|
||||
}
|
||||
.theme-desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.theme-check {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #2d8cf0;
|
||||
color: #fff;
|
||||
font-style: normal;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { themePreviewUrl } from "@/api/module/cms";
|
||||
import { FormModelValue } from "@/utils/themeParamForm";
|
||||
|
||||
/**
|
||||
* 主题预览框(方案 §5.5/§6.3):iframe 加载 /api/theme/preview(同源 CAS 会话,
|
||||
* no-store),参数变更由父级 debounce 后更新 src。内容源站点不存在时提示。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
/** 预览内容源站点编码(取该站栏目/文章/轮播渲染) */
|
||||
siteCode?: string;
|
||||
theme?: string;
|
||||
themeParams?: FormModelValue;
|
||||
}>();
|
||||
|
||||
const src = computed(() => {
|
||||
if (!props.siteCode || !props.theme) return "";
|
||||
return themePreviewUrl(props.siteCode, props.theme, props.themeParams || {});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-preview">
|
||||
<template v-if="!siteCode || !theme">
|
||||
<div class="preview-empty">
|
||||
无可用内容源站点 —— 预览需要借用一个已有站点的内容
|
||||
<p class="hint">请先创建/启用一个站点(或初始化演示数据)后再预览</p>
|
||||
</div>
|
||||
</template>
|
||||
<iframe
|
||||
v-else
|
||||
:key="src"
|
||||
:src="src"
|
||||
class="preview-frame"
|
||||
title="主题预览"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.theme-preview {
|
||||
width: 100%;
|
||||
background: #f0f2f5;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-frame {
|
||||
width: 100%;
|
||||
height: 560px;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
.preview-empty {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
.hint {
|
||||
margin-top: 6px;
|
||||
color: #bbb;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user