598 lines
19 KiB
Vue
598 lines
19 KiB
Vue
<template>
|
||
<dragModal v-model:open="visibleModel" width="85%" :title="title" :mask-closable="false" :fullscreen="fullscreen"
|
||
@cancel="onCancel">
|
||
<div v-if="hasPerson" class="family-chart-wrap">
|
||
<div ref="containerRef" class="family-chart-container"></div>
|
||
<div class="ftc-zoom-controls">
|
||
<button class="ftc-zoom-btn" title="放大" @click="zoomBy(ZOOM_STEP)">+</button>
|
||
<button class="ftc-zoom-btn" title="缩小" @click="zoomBy(-ZOOM_STEP)">-</button>
|
||
<button class="ftc-zoom-btn" title="适应画布" @click="fitGraph">⤢</button>
|
||
</div>
|
||
</div>
|
||
<a-empty v-else description="暂无当前族员数据" />
|
||
<template #footer>
|
||
<a-button type="primary" @click="onCancel">关闭</a-button>
|
||
</template>
|
||
</dragModal>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||
import { Graph } from "@antv/g6";
|
||
import dragModal from "@/components/basic/DragModal.vue";
|
||
import type { FamilyTreeData } from "@/data/auto/familyTreeData";
|
||
import { useLocalePolicy } from "@/hooks/useLocalePolicy";
|
||
|
||
const props = defineProps({
|
||
open: { type: Boolean, default: false },
|
||
data: { type: Object as () => FamilyTreeData, required: true },
|
||
title: { type: String, default: "谱系图" },
|
||
fullscreen: { type: Boolean, default: true },
|
||
loadedNodes: { type: Object, default: () => ({}) },
|
||
});
|
||
const emit = defineEmits(["update:open", "cancel", "select-child"]);
|
||
|
||
const visibleModel = computed({
|
||
get: () => props.open,
|
||
set: (v: boolean) => emit("update:open", v),
|
||
});
|
||
|
||
/** 已展开的节点 personId 集合 */
|
||
const expandedIds = ref<Set<string>>(new Set());
|
||
|
||
const { getPolicyOptions } = useLocalePolicy();
|
||
const genderOptions = getPolicyOptions("GenderPolicy", false);
|
||
const relationTypeOptions = getPolicyOptions("RelationTypePolicy", false);
|
||
|
||
interface PolicyOption {
|
||
BindValue: string;
|
||
DisplayName: string;
|
||
}
|
||
|
||
const getPolicyText = (options: PolicyOption[], value?: string | null) => {
|
||
const o = options.find((x) => x.BindValue === value);
|
||
return o ? o.DisplayName : value ?? "";
|
||
};
|
||
|
||
const genderLabel = (g?: string) => getPolicyText(genderOptions, g);
|
||
const relationLabel = (r?: string) => getPolicyText(relationTypeOptions, r);
|
||
|
||
/** 性别为空时默认按男性处理 */
|
||
const isMale = (g?: string) => {
|
||
if (!g) return true;
|
||
const t = (genderLabel(g) ?? "").toLowerCase();
|
||
return t.includes("男") || t.includes("male") || g === "M" || g === "1";
|
||
};
|
||
|
||
const hasPerson = computed(() => !!props.data?.CurrentPerson?.FamilyPerson);
|
||
|
||
const rootPersonId = computed(() => props.data?.CurrentPerson?.FamilyPerson?.PersonId ?? "");
|
||
|
||
/** personId -> 头像访问地址(本人 / 全族人员 / 已加载族员) */
|
||
const avatarMap = computed(() => {
|
||
const map: Record<string, string> = {};
|
||
const root: any = props.data?.CurrentPerson?.FamilyPerson;
|
||
if (root?.PersonId && root?.AvatarUrl) map[root.PersonId] = root.AvatarUrl;
|
||
((props.data?.CurrentPerson?.FamilyTreePersons ?? []) as any[]).forEach((p: any) => {
|
||
if (p?.PersonId && p?.AvatarUrl) map[p.PersonId] = p.AvatarUrl;
|
||
});
|
||
Object.values((props.loadedNodes ?? {}) as Record<string, any>).forEach((n: any) => {
|
||
const p = n?.person;
|
||
if (p?.PersonId && p?.AvatarUrl) map[p.PersonId] = p.AvatarUrl;
|
||
});
|
||
return map;
|
||
});
|
||
|
||
/** HTML 字符串转义,防止人名中的特殊字符破坏卡片结构 */
|
||
const esc = (v?: string | null) =>
|
||
(v ?? "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
|
||
/** 无头像时的默认头像图标(白底 + 性别色人像:男蓝 / 女粉) */
|
||
const USER_ICON_SVG =
|
||
'<svg viewBox="64 64 896 896" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M858.5 763.6a374 374 0 00-80.6-119.5 375.63 375.63 0 00-119.5-80.6c-.4-.2-.8-.3-1.2-.5C719.5 518 760 444.7 760 362c0-137-111-248-248-248S264 225 264 362c0 82.7 40.5 156 102.8 201.1-.4.2-.8.3-1.2.5-44.8 18.9-85 46-119.5 80.6a375.63 375.63 0 00-80.6 119.5A371.7 371.7 0 00136 901.8a8 8 0 008 8.2h60c4.4 0 7.9-3.5 8-7.8 2-77.2 33-149.5 87.8-204.3 56.7-56.7 132-87.9 212.2-87.9s155.5 31.2 212.2 87.9C779 752.7 810 825 812 902.2c.1 4.4 3.6 7.8 8 7.8h60a8 8 0 008-8.2c-1-47.8-10.9-94.3-29.5-138.2zM512 534c-45.9 0-89.1-17.9-121.6-50.4S340 407.9 340 362c0-45.9 17.9-89.1 50.4-121.6S466.1 190 512 190s89.1 17.9 121.6 50.4S684 316.1 684 362c0 45.9-17.9 89.1-50.4 121.6S557.9 534 512 534z"></path></svg>';
|
||
|
||
/** 自定义人员卡片 HTML */
|
||
const renderCard = (d: any) => {
|
||
const data = d?.data ?? {};
|
||
const male = isMale(data.gender);
|
||
// 本人=橙色,男性=蓝色,女性=粉色
|
||
const theme = data.kind === "self" ? "#fa8c16" : male ? "#1890ff" : "#eb2f96";
|
||
// 无头像占位图标颜色:男蓝 / 女粉(白底)
|
||
const avatarColor = male ? "#1890ff" : "#eb2f96";
|
||
// 有头像显示头像,无头像显示默认头像(白底 + 性别色人像图标)
|
||
const avatar = data.avatarUrl
|
||
? `<img class="ftc-avatar" src="${esc(data.avatarUrl)}" alt="" onerror="this.style.visibility='hidden'" />`
|
||
: "";
|
||
const common = data.commonName
|
||
? `<div class="ftc-sub">常用名:${esc(data.commonName)}</div>`
|
||
: "";
|
||
const actionTag = data.hasChildren
|
||
? `<div class="ftc-tag" style="color:#13c2c2; background:#13c2c21a;">${data.expanded ? "点击折叠" : "点击展开"}</div>`
|
||
: !data.loaded
|
||
? `<div class="ftc-tag" style="color:#722ed1; background:#722ed11a;">点击查看</div>`
|
||
: "";
|
||
const clickable = data.hasChildren || !data.loaded ? "ftc-clickable" : "";
|
||
return `
|
||
<div class="ftc-card ftc-card-lg ${clickable}" style="border-top: 3px solid ${theme};">
|
||
<div class="ftc-main">
|
||
<div class="ftc-avatar-wrap" style="border-color:${theme};">
|
||
<span class="ftc-avatar-ph" style="color:${avatarColor};">${USER_ICON_SVG}</span>${avatar}
|
||
</div>
|
||
<div class="ftc-info">
|
||
<div class="ftc-name" title="${esc(data.name)}">${esc(data.name)}</div>
|
||
${common}
|
||
</div>
|
||
</div>
|
||
<div class="ftc-footer">
|
||
<div class="ftc-tag" style="color:${theme}; background:${theme}1a;">${esc(data.tag)}</div>
|
||
${actionTag}
|
||
</div>
|
||
</div>`;
|
||
};
|
||
|
||
/** 同级(配偶/孩子)按 Orderby 升序排列 */
|
||
const byOrder = (a: any, b: any) => (a.Orderby ?? 0) - (b.Orderby ?? 0);
|
||
|
||
/** 每个族员的描述:人员信息 + 配偶 + 孩子(根来自 data,其他来自 loadedNodes) */
|
||
const describePerson = (personId: string): any => {
|
||
if (personId === rootPersonId.value) {
|
||
const dp = props.data?.CurrentPerson;
|
||
if (!dp?.FamilyPerson) return null;
|
||
return {
|
||
person: dp.FamilyPerson,
|
||
spouses: [...(dp.FamilyTreeSpouseMaps ?? [])].sort(byOrder),
|
||
children: [...(dp.FamilyTreeChildrenMaps ?? [])].sort(byOrder),
|
||
loaded: true,
|
||
};
|
||
}
|
||
const loaded = (props.loadedNodes as Record<string, any>)?.[personId];
|
||
if (loaded) {
|
||
return {
|
||
person: loaded.person,
|
||
spouses: [...(loaded.spouses ?? [])].sort(byOrder),
|
||
children: [...(loaded.children ?? [])].sort(byOrder),
|
||
loaded: true,
|
||
};
|
||
}
|
||
return null;
|
||
};
|
||
|
||
/** 构建图数据:树形竖向排列,本人默认展开孩子,点击孩子可递归加载子树 */
|
||
const buildGraphData = () => {
|
||
const nodes: any[] = [];
|
||
const edges: any[] = [];
|
||
const root = rootPersonId.value;
|
||
if (!root) return { nodes, edges };
|
||
|
||
const CARD_WIDTH = 360;
|
||
const CARD_HEIGHT = 220;
|
||
const H_GAP = 60;
|
||
const V_GAP = 40;
|
||
const X_STEP = CARD_WIDTH + H_GAP; // 420,避免水平重叠
|
||
const Y_STEP = CARD_HEIGHT + V_GAP; // 232,避免垂直重叠
|
||
const SPOUSE_GAP = X_STEP;
|
||
let cursor = 0;
|
||
|
||
const walk = (personId: string, depth: number, xBase: number) => {
|
||
const desc = describePerson(personId);
|
||
if (!desc) return;
|
||
|
||
const isRoot = personId === root;
|
||
const expanded = expandedIds.value.has(personId);
|
||
const hasChildren = desc.children.length > 0;
|
||
const p = desc.person;
|
||
const x = xBase;
|
||
const y = cursor;
|
||
cursor += Y_STEP;
|
||
|
||
nodes.push({
|
||
id: "n-" + personId,
|
||
style: { x, y },
|
||
data: {
|
||
kind: isRoot ? "self" : "member",
|
||
personId,
|
||
tag: isRoot ? "本人" : "族员",
|
||
name: (p?.Surname ?? "") + (p?.GenealogyName ?? ""),
|
||
commonName: p?.CommonName,
|
||
gender: p?.Gender,
|
||
avatarUrl: p?.AvatarUrl ?? avatarMap.value[personId] ?? "",
|
||
hasChildren,
|
||
expanded,
|
||
loaded: desc.loaded,
|
||
},
|
||
});
|
||
|
||
// 配偶与本人同一行,水平间隔 X_STEP 对齐
|
||
desc.spouses.forEach((s: any, i: number) => {
|
||
const sid = "s-" + personId + "-" + i;
|
||
nodes.push({
|
||
id: sid,
|
||
style: { x: xBase + X_STEP + i * SPOUSE_GAP, y },
|
||
data: {
|
||
kind: "spouse",
|
||
personId: s.SpouseId,
|
||
tag: "配偶",
|
||
name: (s.SpouseSurname ?? "") + (s.SpouseGenealogyName ?? ""),
|
||
gender: s.Gender,
|
||
avatarUrl: avatarMap.value[s.SpouseId] ?? "",
|
||
loaded: true,
|
||
hasChildren: false,
|
||
expanded: false,
|
||
},
|
||
});
|
||
edges.push({ source: "n-" + personId, target: sid, data: { kind: "spouse" } });
|
||
});
|
||
|
||
if (expanded) {
|
||
// 孩子列与本人第一行的配偶列对齐(xBase + X_STEP),即孩子位于配偶正下方,竖直跟第一行对齐
|
||
const childBaseX = xBase + X_STEP;
|
||
|
||
desc.children.forEach((c: any) => {
|
||
const childId = c.ChildrenId;
|
||
if (describePerson(childId)) {
|
||
walk(childId, depth + 1, childBaseX);
|
||
} else {
|
||
nodes.push({
|
||
id: "n-" + childId,
|
||
style: { x: childBaseX, y: cursor },
|
||
data: {
|
||
kind: "child",
|
||
personId: childId,
|
||
tag: c.RelationType ? relationLabel(c.RelationType) : "子女",
|
||
name: (c.ChildrenSurname ?? "") + (c.ChildrenGenealogyName ?? ""),
|
||
commonName: c.ChildrenCommonName,
|
||
gender: c.Gender,
|
||
avatarUrl: avatarMap.value[childId] ?? "",
|
||
hasChildren: false,
|
||
expanded: false,
|
||
loaded: false,
|
||
},
|
||
});
|
||
cursor += Y_STEP;
|
||
}
|
||
edges.push({ source: "n-" + personId, target: "n-" + childId, data: { kind: "parent" } });
|
||
});
|
||
}
|
||
};
|
||
|
||
walk(root, 0, 0);
|
||
return { nodes, edges };
|
||
};
|
||
|
||
const containerRef = ref<HTMLDivElement>();
|
||
let graph: any = null;
|
||
|
||
/** 卡片最小显示比例:世代过多时 fitView 会把整图缩得很小,卡片将看不清,低于该比例时放大 */
|
||
const MIN_ZOOM = 0.4;
|
||
|
||
/** 缩放按钮单次增量 */
|
||
const ZOOM_STEP = 0.1;
|
||
|
||
/** 单击按钮放大/缩小一点点(按增量叠加并限制在 zoomRange 内) */
|
||
const zoomBy = (delta: number) => {
|
||
if (!graph) return;
|
||
const next = graph.getZoom() + delta;
|
||
graph.zoomTo(Math.min(Math.max(next, MIN_ZOOM), 10));
|
||
};
|
||
|
||
/** 还原为适配整个画布 */
|
||
const fitGraph = async () => {
|
||
if (!graph) return;
|
||
await graph.fitView();
|
||
if (graph.getZoom() < MIN_ZOOM) {
|
||
await graph.zoomTo(MIN_ZOOM);
|
||
}
|
||
};
|
||
|
||
const destroyGraph = () => {
|
||
if (graph) {
|
||
graph.destroy();
|
||
graph = null;
|
||
}
|
||
};
|
||
|
||
const initGraph = async () => {
|
||
destroyGraph();
|
||
await nextTick();
|
||
// 等待弹窗动画完成,确保容器有实际尺寸
|
||
await new Promise((r) => requestAnimationFrame(r));
|
||
if (!containerRef.value) return;
|
||
|
||
const { nodes, edges } = buildGraphData();
|
||
if (!nodes.length) return;
|
||
|
||
graph = new Graph({
|
||
container: containerRef.value,
|
||
// 内容小于视口时不缩放(卡片保持原始大小),内容溢出时才适配
|
||
autoFit: { type: "view", options: { when: "overflow", direction: "both" } },
|
||
zoomRange: [MIN_ZOOM, 10],
|
||
data: { nodes, edges },
|
||
node: {
|
||
type: "html",
|
||
style: {
|
||
size: [360, 220],
|
||
dx: -180,
|
||
dy: -110,
|
||
// 官方文档推荐函数形式,类型声明仅支持 string/HTMLElement,故断言
|
||
innerHTML: renderCard as any,
|
||
},
|
||
},
|
||
edge: {
|
||
type: (d: any) => (d?.data?.kind === "spouse" ? "line" : "polyline"),
|
||
style: (d: any) => ({
|
||
// 夫妻关系=紫色虚线,亲子关系=灰色实线
|
||
stroke: d?.data?.kind === "spouse" ? "#722ed1" : "#a0a0a0",
|
||
lineWidth: 1.5,
|
||
lineDash: d?.data?.kind === "spouse" ? [4, 4] : undefined,
|
||
router: d?.data?.kind === "spouse" ? undefined : { type: "orth" },
|
||
}),
|
||
},
|
||
behaviors: ["drag-canvas", "zoom-canvas"],
|
||
});
|
||
graph.on("node:click", (event: any) => {
|
||
const id = event.target?.id ?? event.item?.getID?.();
|
||
if (typeof id !== "string" || !id.startsWith("n-")) return;
|
||
const personId = id.slice(2);
|
||
const isRoot = personId === rootPersonId.value;
|
||
if (isRoot) {
|
||
const children = props.data?.CurrentPerson?.FamilyTreeChildrenMaps ?? [];
|
||
if (children.length > 0) {
|
||
toggleExpanded(personId);
|
||
}
|
||
return;
|
||
}
|
||
if (describePerson(personId)) {
|
||
toggleExpanded(personId);
|
||
} else {
|
||
emit("select-child", personId);
|
||
}
|
||
});
|
||
await graph.render();
|
||
// 世代过多导致 fitView 缩得太小时,放大到最小显示比例,保证卡片可读
|
||
if (graph.getZoom() < MIN_ZOOM) {
|
||
await graph.zoomTo(MIN_ZOOM);
|
||
}
|
||
};
|
||
|
||
const toggleExpanded = (personId: string) => {
|
||
const set = new Set(expandedIds.value);
|
||
if (set.has(personId)) set.delete(personId);
|
||
else set.add(personId);
|
||
expandedIds.value = set;
|
||
initGraph();
|
||
};
|
||
|
||
watch(visibleModel, async (v) => {
|
||
if (v) {
|
||
// 第一个人(当前族员)默认展开孩子
|
||
expandedIds.value = new Set([rootPersonId.value].filter(Boolean));
|
||
await initGraph();
|
||
} else {
|
||
destroyGraph();
|
||
}
|
||
});
|
||
|
||
watch(
|
||
() => props.data,
|
||
async () => {
|
||
if (visibleModel.value) {
|
||
expandedIds.value = new Set([rootPersonId.value].filter(Boolean));
|
||
await initGraph();
|
||
}
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
watch(
|
||
() => props.loadedNodes,
|
||
async (val) => {
|
||
if (!val || !visibleModel.value) return;
|
||
// 新加载的族员自动展开,方便查看其子树
|
||
const set = new Set(expandedIds.value);
|
||
Object.keys(val as Record<string, any>).forEach((k) => set.add(k));
|
||
expandedIds.value = set;
|
||
await initGraph();
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
onBeforeUnmount(() => destroyGraph());
|
||
|
||
const onCancel = () => {
|
||
visibleModel.value = false;
|
||
emit("cancel");
|
||
};
|
||
</script>
|
||
|
||
<style>
|
||
.family-chart-wrap {
|
||
position: relative;
|
||
height: 100%;
|
||
min-height: 480px;
|
||
}
|
||
|
||
.family-chart-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: #fafafa;
|
||
border: 1px solid #f0f0f0;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 画布缩放按钮组(放大/缩小/适应) */
|
||
.ftc-zoom-controls {
|
||
position: absolute;
|
||
top: 12px;
|
||
right: 12px;
|
||
z-index: 20;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.ftc-zoom-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
border: 1px solid #d9d9d9;
|
||
border-radius: 6px;
|
||
background: #fff;
|
||
color: #333;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: color 0.2s, border-color 0.2s;
|
||
}
|
||
|
||
.ftc-zoom-btn:hover {
|
||
color: #1890ff;
|
||
border-color: #1890ff;
|
||
}
|
||
|
||
/* G6 HTML 节点卡片样式(注入到 canvas 容器,使用独立前缀避免污染全局) */
|
||
.ftc-card {
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: #fff;
|
||
border: 1px solid #e8e8e8;
|
||
border-radius: 6px;
|
||
padding: 8px 10px;
|
||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||
display: flex;
|
||
flex-direction: column;
|
||
user-select: none;
|
||
}
|
||
|
||
/* 谱系图大卡片(节点尺寸放大 2 倍,内部文字同步放大) */
|
||
.ftc-card-lg {
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
.ftc-card-lg .ftc-name {
|
||
font-size: 20px;
|
||
}
|
||
|
||
.ftc-card-lg .ftc-sub {
|
||
font-size: 15px;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.ftc-card-lg .ftc-tag {
|
||
font-size: 13px;
|
||
padding: 4px 8px;
|
||
}
|
||
|
||
.ftc-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
/* 人员卡片:左侧头像 + 右侧姓名信息 */
|
||
.ftc-main {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.ftc-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.ftc-avatar-wrap {
|
||
position: relative;
|
||
flex: none;
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 2px solid #e8e8e8;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #fff;
|
||
}
|
||
|
||
.ftc-card-lg .ftc-avatar-wrap {
|
||
width: 104px;
|
||
height: 104px;
|
||
}
|
||
|
||
/* 无头像占位:白底 + 性别色人像图标(男蓝 / 女粉,颜色由内联 style 指定) */
|
||
.ftc-avatar-ph {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 100%;
|
||
color: #1890ff;
|
||
font-size: 20px;
|
||
line-height: 1;
|
||
}
|
||
|
||
.ftc-card-lg .ftc-avatar-ph {
|
||
font-size: 52px;
|
||
}
|
||
|
||
/* 头像图片绝对定位覆盖占位图标,加载失败时隐藏,露出下方默认头像 */
|
||
.ftc-avatar {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.ftc-name {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.ftc-sub {
|
||
font-size: 12px;
|
||
color: #888;
|
||
margin-top: 4px;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.ftc-footer {
|
||
margin-top: auto;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
width: 100%;
|
||
}
|
||
|
||
.ftc-tag {
|
||
font-size: 11px;
|
||
line-height: 1;
|
||
border-radius: 3px;
|
||
padding: 3px 6px;
|
||
}
|
||
|
||
.ftc-clickable {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.ftc-clickable:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||
}
|
||
</style>
|