| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836 |
- <!doctype html>
- <html lang="zh-CN">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>申勤物业元知识图谱 3D</title>
- <style>
- :root {
- --bg: #f4f7fb;
- --panel: #ffffff;
- --border: #d9e1ec;
- --primary: #2f5597;
- --text: #22303f;
- --muted: #7a8794;
- }
- * { box-sizing: border-box; }
- body {
- margin: 0;
- font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
- background: var(--bg);
- color: var(--text);
- height: 100vh;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- header {
- height: 58px;
- padding: 0 18px;
- display: flex;
- align-items: center;
- gap: 10px;
- border-bottom: 1px solid var(--border);
- background: #fff;
- }
- header h1 {
- margin: 0;
- font-size: 18px;
- color: var(--primary);
- }
- .spacer { flex: 1; }
- button {
- border: 1px solid var(--border);
- background: #fff;
- border-radius: 9px;
- padding: 8px 12px;
- cursor: pointer;
- font-size: 13px;
- font-family: inherit;
- }
- button:hover { background: #eef3fb; }
- .workspace {
- flex: 1;
- position: relative;
- overflow: hidden;
- }
- #graph3D {
- position: absolute;
- inset: 0;
- background:
- radial-gradient(circle at 50% 42%, rgba(255, 255, 255, 0.88) 0%, rgba(239, 245, 252, 0.86) 45%, rgba(222, 232, 245, 0.92) 100%),
- radial-gradient(circle at 16% 18%, rgba(47, 85, 151, 0.16), transparent 32%),
- radial-gradient(circle at 82% 16%, rgba(14, 116, 144, 0.14), transparent 28%),
- radial-gradient(circle at 72% 84%, rgba(124, 58, 237, 0.12), transparent 34%),
- radial-gradient(circle at 25% 78%, rgba(217, 119, 6, 0.08), transparent 30%),
- linear-gradient(rgba(47, 85, 151, 0.045) 1px, transparent 1px),
- linear-gradient(90deg, rgba(47, 85, 151, 0.045) 1px, transparent 1px);
- background-size: auto, auto, auto, auto, auto, 42px 42px, 42px 42px;
- }
- #graph3D canvas {
- display: block;
- cursor: grab;
- }
- #graph3D canvas:active {
- cursor: grabbing;
- }
- #nodePanel {
- position: absolute;
- top: 16px;
- right: 16px;
- width: 330px;
- max-height: calc(100% - 32px);
- overflow: auto;
- background: var(--panel);
- border: 1px solid var(--border);
- border-radius: 14px;
- box-shadow: 0 14px 36px rgba(35, 48, 63, 0.16);
- padding: 16px;
- display: none;
- }
- #nodePanel.show { display: block; }
- #nodePanel h2 { margin: 0 0 10px; font-size: 20px; color: var(--primary); }
- .meta-list {
- display: grid;
- grid-template-columns: 96px 1fr;
- row-gap: 8px;
- column-gap: 8px;
- font-size: 13px;
- }
- .meta-list dt { color: var(--muted); }
- .meta-list dd {
- margin: 0;
- word-break: break-word;
- }
- .attrs-title {
- margin: 16px 0 8px;
- font-size: 14px;
- color: var(--primary);
- }
- .attrs {
- display: flex;
- flex-wrap: wrap;
- gap: 7px;
- margin-top: 0;
- }
- .attr-tag {
- background: #eef3fb;
- color: var(--primary);
- border: 1px solid #ccdbf5;
- border-radius: 999px;
- padding: 5px 9px;
- font-size: 12px;
- }
- #errorOverlay {
- position: absolute;
- inset: 0;
- display: none;
- align-items: center;
- justify-content: center;
- color: #c0392b;
- background: rgba(244, 247, 251, 0.94);
- font-size: 15px;
- text-align: center;
- padding: 24px;
- }
- #errorOverlay.show { display: flex; }
- .chat-dock {
- position: absolute;
- right: 18px;
- bottom: 18px;
- width: 430px;
- height: 620px;
- max-width: calc(100% - 36px);
- max-height: calc(100% - 36px);
- display: flex;
- flex-direction: column;
- background: #ffffff;
- border: 1px solid var(--border);
- border-radius: 16px;
- box-shadow: 0 18px 50px rgba(35, 48, 63, 0.18);
- overflow: hidden;
- }
- .chat-resize-handle {
- position: absolute;
- z-index: 6;
- touch-action: none;
- user-select: none;
- -webkit-user-select: none;
- }
- .chat-resize-handle.nw { top: 0; left: 0; width: 16px; height: 16px; cursor: nwse-resize; }
- .chat-resize-handle.n { top: 0; left: 20px; right: 20px; height: 8px; cursor: ns-resize; }
- .chat-resize-handle.ne { top: 0; right: 0; width: 16px; height: 16px; cursor: nesw-resize; }
- .chat-resize-handle.e { top: 20px; right: 0; bottom: 20px; width: 8px; cursor: ew-resize; }
- .chat-resize-handle.se { bottom: 0; right: 0; width: 16px; height: 16px; cursor: nwse-resize; }
- .chat-resize-handle.s { bottom: 0; left: 20px; right: 20px; height: 8px; cursor: ns-resize; }
- .chat-resize-handle.sw { bottom: 0; left: 0; width: 16px; height: 16px; cursor: nesw-resize; }
- .chat-resize-handle.w { top: 20px; left: 0; bottom: 20px; width: 8px; cursor: ew-resize; }
- .chat-dock.collapsed {
- display: none;
- }
- .chat-fab {
- position: absolute;
- right: 18px;
- bottom: 18px;
- width: 58px;
- height: 58px;
- border-radius: 50%;
- border: 2px solid var(--primary);
- padding: 0;
- overflow: hidden;
- cursor: pointer;
- box-shadow: 0 10px 24px rgba(35, 48, 63, 0.22);
- z-index: 7;
- display: none;
- }
- .chat-fab.show {
- display: block;
- }
- .chat-fab img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- display: block;
- }
- .chat-dock.collapsed .chat-resize-handle {
- display: none;
- }
- .chat-dock-header {
- height: 42px;
- padding: 0 24px;
- display: flex;
- align-items: center;
- gap: 8px;
- background: var(--primary);
- color: #fff;
- font-size: 14px;
- cursor: move;
- user-select: none;
- -webkit-user-select: none;
- touch-action: none;
- }
- .chat-dock-header .spacer { flex: 1; }
- .chat-toggle {
- background: rgba(255, 255, 255, 0.15);
- color: #fff;
- border: 1px solid rgba(255, 255, 255, 0.28);
- padding: 5px 9px;
- border-radius: 8px;
- cursor: pointer;
- }
- #chatFrame {
- flex: 1;
- border: 0;
- display: block;
- }
- .chat-dock.collapsed #chatFrame {
- display: none;
- }
- </style>
- <link rel="stylesheet" href="data_quality.css?v=20260904">
- </head>
- <body>
- <header>
- <h1>申勤物业元知识图谱 3D</h1>
- <div class="spacer"></div>
- <button id="resetBtn">重置视角</button>
- <button onclick="location.href='index.html'">返回聊天</button>
- </header>
- <div class="workspace">
- <div id="graph3D"></div>
- <div id="errorOverlay"></div>
- <aside id="nodePanel">
- <h2 id="panelTitle"></h2>
- <dl class="meta-list" id="panelMeta"></dl>
- <h3 class="attrs-title">属性</h3>
- <div class="attrs" id="panelAttrs"></div>
- </aside>
- <button id="chatFab" class="chat-fab" title="展开聊天窗口">
- <img src="微信图片_20260817170012_14_2.png" alt="打开聊天" />
- </button>
- <div id="chatDock" class="chat-dock">
- <div class="chat-resize-handle nw" data-resize="nw" title="缩放"></div>
- <div class="chat-resize-handle n" data-resize="n" title="上下缩放"></div>
- <div class="chat-resize-handle ne" data-resize="ne" title="缩放"></div>
- <div class="chat-resize-handle e" data-resize="e" title="左右缩放"></div>
- <div class="chat-resize-handle se" data-resize="se" title="缩放"></div>
- <div class="chat-resize-handle s" data-resize="s" title="上下缩放"></div>
- <div class="chat-resize-handle sw" data-resize="sw" title="缩放"></div>
- <div class="chat-resize-handle w" data-resize="w" title="左右缩放"></div>
- <div class="chat-dock-header">
- <span>申勤物业知识助手</span>
- <div class="spacer"></div>
- <button id="chatToggle" class="chat-toggle">收起</button>
- </div>
- <iframe id="chatFrame" src="index.html?v=20260907-cache" title="申勤物业知识助手"></iframe>
- </div>
- </div>
- <script src="vendor/three.min.js"></script>
- <script src="vendor/OrbitControls.js"></script>
- <script>
- const $ = (id) => document.getElementById(id);
- const container = $("graph3D");
- const nodePanel = $("nodePanel");
- const panelTitle = $("panelTitle");
- const panelMeta = $("panelMeta");
- const panelAttrs = $("panelAttrs");
- const chatDock = $("chatDock");
- const chatToggle = $("chatToggle");
- const errorOverlay = $("errorOverlay");
- const chatResizeHandles = Array.from(document.querySelectorAll(".chat-resize-handle"));
- const chatHeader = document.querySelector(".chat-dock-header");
- const chatFab = $("chatFab");
- let resizeState = null;
- let chatDragState = null;
- const departmentColors = {
- "市场部": "#d97706",
- "人事部": "#7c3aed",
- "运营部": "#2f855a",
- "财务部": "#c0392b",
- "采购部": "#0e7490",
- };
- const fallbackColors = ["#4c78a8", "#b45309", "#6d28d9", "#15803d", "#be123c", "#0369a1", "#b45309"];
- function apiBase() {
- if (location.protocol === "http:" || location.protocol === "https:") {
- return location.origin;
- }
- return "http://127.0.0.1:8000";
- }
- function showError(message) {
- errorOverlay.textContent = message;
- errorOverlay.classList.add("show");
- }
- function colorForNode(node) {
- const department = Array.isArray(node.department) && node.department.length
- ? node.department[0]
- : "未分类";
- const known = departmentColors[department];
- if (known) return known;
- let hash = 0;
- const key = String(node.id || node.name || "");
- for (let i = 0; i < key.length; i += 1) {
- hash = (hash * 31 + key.charCodeAt(i)) >>> 0;
- }
- return fallbackColors[hash % fallbackColors.length];
- }
- function hexToNumber(hex) {
- return parseInt(hex.slice(1), 16);
- }
- function showNode(node) {
- panelTitle.textContent = node.name;
- panelMeta.innerHTML = "";
- const attributes = node.attributes || [];
- const meta = [
- ["所属部门", (node.department || []).join("、")],
- ["是否中心节点", node.is_hub ? "是" : "否"],
- ["是否启用", node.active ? "是" : "否"],
- ["是否建议节点", node.suggested ? "是" : "否"],
- ["属性数量", String(attributes.length)],
- ];
- const description = (node.description || "").trim();
- const attributesText = attributes.join(" / ");
- if (description && description.replace(/\s+/g, "") !== attributesText.replace(/\s+/g, "")) {
- meta.push(["说明", description]);
- }
- meta.forEach(([k, v]) => {
- const dt = document.createElement("dt");
- dt.textContent = k;
- const dd = document.createElement("dd");
- dd.textContent = v;
- panelMeta.appendChild(dt);
- panelMeta.appendChild(dd);
- });
- panelAttrs.innerHTML = "";
- if (!attributes.length) {
- const empty = document.createElement("span");
- empty.className = "attr-tag";
- empty.textContent = "暂无属性";
- panelAttrs.appendChild(empty);
- } else {
- attributes.forEach((attr) => {
- const tag = document.createElement("span");
- tag.className = "attr-tag";
- tag.textContent = attr;
- tag.title = attr;
- panelAttrs.appendChild(tag);
- });
- }
- nodePanel.classList.add("show");
- }
- function init() {
- if (typeof THREE === "undefined" || typeof THREE.OrbitControls === "undefined") {
- showError("3D 渲染库加载失败,请确认 html/vendor 目录下的 three.min.js 和 OrbitControls.js 存在。");
- return;
- }
- const scene = new THREE.Scene();
- const camera = new THREE.PerspectiveCamera(
- 50,
- container.clientWidth / Math.max(container.clientHeight, 1),
- 0.1,
- 5000,
- );
- camera.position.set(0, 180, 780);
- const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
- renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
- renderer.setSize(container.clientWidth, container.clientHeight);
- renderer.setClearColor(0xf4f7fb, 0);
- container.appendChild(renderer.domElement);
- const controls = new THREE.OrbitControls(camera, renderer.domElement);
- controls.enableDamping = true;
- controls.dampingFactor = 0.08;
- controls.minDistance = 240;
- controls.maxDistance = 1500;
- controls.autoRotate = true;
- controls.autoRotateSpeed = 0.75;
- controls.target.set(0, 0, 0);
- const ambient = new THREE.AmbientLight(0xffffff, 0.78);
- scene.add(ambient);
- const keyLight = new THREE.DirectionalLight(0xffffff, 1.0);
- keyLight.position.set(360, 520, 520);
- scene.add(keyLight);
- const fillLight = new THREE.DirectionalLight(0xffffff, 0.35);
- fillLight.position.set(-420, -180, -360);
- scene.add(fillLight);
- const graphGroup = new THREE.Group();
- scene.add(graphGroup);
- const nodeMeshes = new Map();
- const edgeItems = [];
- function buildLayout(nodes) {
- const positions = new Map();
- const n = nodes.length || 1;
- const R = 290;
- const goldenAngle = Math.PI * (3 - Math.sqrt(5));
- nodes.forEach((node, i) => {
- let y;
- let radiusAtY;
- if (n === 1) {
- y = 0;
- radiusAtY = 1;
- } else {
- y = 1 - (i / (n - 1)) * 2;
- radiusAtY = Math.sqrt(Math.max(0, 1 - y * y));
- }
- const theta = goldenAngle * i;
- positions.set(node.id, new THREE.Vector3(
- Math.cos(theta) * radiusAtY * R,
- y * R * 0.72,
- Math.sin(theta) * radiusAtY * R,
- ));
- });
- return positions;
- }
- function makeTextSprite(text, options) {
- const opts = options || {};
- const width = opts.width || 256;
- const height = opts.height || 64;
- const canvas = document.createElement("canvas");
- canvas.width = width;
- canvas.height = height;
- const ctx = canvas.getContext("2d");
- ctx.clearRect(0, 0, width, height);
- ctx.font = `${opts.weight || 600} ${opts.fontSize || 28}px "Microsoft YaHei", "PingFang SC", sans-serif`;
- ctx.textAlign = "center";
- ctx.textBaseline = "middle";
- ctx.lineWidth = opts.strokeWidth || 6;
- ctx.strokeStyle = opts.stroke || "rgba(255, 255, 255, 0.95)";
- ctx.strokeText(text, width / 2, height / 2);
- ctx.fillStyle = opts.color || "#1f2a3a";
- ctx.fillText(text, width / 2, height / 2);
- const texture = new THREE.CanvasTexture(canvas);
- texture.minFilter = THREE.LinearFilter;
- const material = new THREE.SpriteMaterial({
- map: texture,
- depthTest: false,
- depthWrite: false,
- transparent: true,
- });
- const sprite = new THREE.Sprite(material);
- sprite.scale.set(opts.scaleX || 120, opts.scaleY || 30, 1);
- return sprite;
- }
- function selfLoopPoints(position, offset) {
- const points = [];
- const steps = 72;
- for (let i = 0; i <= steps; i += 1) {
- const angle = (Math.PI * 2 * i) / steps;
- points.push(new THREE.Vector3(
- position.x + Math.cos(angle) * 22,
- position.y + offset,
- position.z + Math.sin(angle) * 22,
- ));
- }
- return points;
- }
- function buildGraph(data) {
- const nodes = data?.nodes || [];
- const relations = data?.relations || [];
- const positions = buildLayout(nodes);
- const edgeMaterial = new THREE.LineBasicMaterial({
- color: 0x8fa3c0,
- transparent: true,
- opacity: 0.52,
- });
- const selfLoopCounts = {};
- relations.forEach((rel) => {
- const a = positions.get(rel.source);
- const b = positions.get(rel.target);
- if (!a || !b) return;
- if (rel.source === rel.target) {
- const count = selfLoopCounts[rel.source] || 0;
- selfLoopCounts[rel.source] = count + 1;
- const offset = 34 + count * 22;
- const geometry = new THREE.BufferGeometry().setFromPoints(selfLoopPoints(a, offset));
- const line = new THREE.Line(geometry, edgeMaterial);
- graphGroup.add(line);
- const label = makeTextSprite(rel.type, {
- fontSize: 20,
- color: "#1f2a3a",
- stroke: "rgba(244, 247, 251, 0.95)",
- strokeWidth: 5,
- scaleX: 82,
- scaleY: 20,
- });
- label.position.set(a.x, a.y + offset + 12, a.z);
- graphGroup.add(label);
- edgeItems.push({ line, label, a, b, self: true, offset });
- } else {
- const geometry = new THREE.BufferGeometry().setFromPoints([a, b]);
- const line = new THREE.Line(geometry, edgeMaterial);
- graphGroup.add(line);
- const mid = new THREE.Vector3().addVectors(a, b).multiplyScalar(0.5);
- const label = makeTextSprite(rel.type, {
- fontSize: 20,
- color: "#1f2a3a",
- stroke: "rgba(244, 247, 251, 0.95)",
- strokeWidth: 5,
- scaleX: 82,
- scaleY: 20,
- });
- label.position.copy(mid);
- graphGroup.add(label);
- edgeItems.push({ line, label, a, b, self: false, offset: 0 });
- }
- });
- const sphereGeometry = new THREE.SphereGeometry(1, 40, 28);
- nodes.forEach((node) => {
- const p = positions.get(node.id);
- if (!p) return;
- const radius = node.is_hub ? 22 : 14;
- const material = new THREE.MeshPhongMaterial({
- color: hexToNumber(colorForNode(node)),
- emissive: new THREE.Color(hexToNumber(colorForNode(node))).multiplyScalar(0.16),
- shininess: 28,
- specular: 0x556677,
- });
- const mesh = new THREE.Mesh(sphereGeometry, material);
- mesh.position.copy(p);
- mesh.scale.setScalar(radius);
- mesh.userData = { node };
- graphGroup.add(mesh);
- nodeMeshes.set(node.id, mesh);
- const label = makeTextSprite(node.name, {
- fontSize: 28,
- color: "#1f2a3a",
- stroke: "rgba(255, 255, 255, 0.95)",
- strokeWidth: 6,
- scaleX: 132,
- scaleY: 34,
- });
- label.position.set(p.x, p.y + radius + 18, p.z);
- graphGroup.add(label);
- });
- }
- const raycaster = new THREE.Raycaster();
- const pointer = new THREE.Vector2();
- let pointerDown = null;
- function pickNode(event) {
- const rect = renderer.domElement.getBoundingClientRect();
- pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
- pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
- raycaster.setFromCamera(pointer, camera);
- const hits = raycaster.intersectObjects(Array.from(nodeMeshes.values()), false);
- if (hits.length) {
- showNode(hits[0].object.userData.node);
- }
- }
- renderer.domElement.addEventListener("pointerdown", (event) => {
- pointerDown = { x: event.clientX, y: event.clientY };
- });
- renderer.domElement.addEventListener("pointerup", (event) => {
- if (!pointerDown) return;
- const moved = Math.hypot(event.clientX - pointerDown.x, event.clientY - pointerDown.y);
- pointerDown = null;
- if (moved < 6) pickNode(event);
- });
- renderer.domElement.addEventListener("pointermove", (event) => {
- const rect = renderer.domElement.getBoundingClientRect();
- pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
- pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
- raycaster.setFromCamera(pointer, camera);
- const hits = raycaster.intersectObjects(Array.from(nodeMeshes.values()), false);
- renderer.domElement.style.cursor = hits.length ? "pointer" : "grab";
- });
- $("resetBtn").addEventListener("click", () => {
- camera.position.set(0, 180, 780);
- controls.target.set(0, 0, 0);
- controls.update();
- });
- window.addEventListener("resize", () => {
- const width = container.clientWidth;
- const height = container.clientHeight;
- camera.aspect = width / Math.max(height, 1);
- camera.updateProjectionMatrix();
- renderer.setSize(width, height);
- });
- async function load() {
- const res = await fetch(`${apiBase()}/output/meta_graph_schema_display.json`, { cache: "no-store" });
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const data = await res.json();
- buildGraph(data);
- const displayedSchemaVersion = data?.meta?.build_id;
- setInterval(async () => {
- try {
- const response = await fetch(`${apiBase()}/api/runtime`, { cache: "no-store" });
- if (!response.ok) return;
- const current = await response.json();
- if (current.schema_version && current.schema_version !== displayedSchemaVersion) {
- window.location.reload();
- }
- } catch (_) { /* Keep the currently displayed graph during brief service outages. */ }
- }, 10000);
- }
- load().catch((err) => {
- showError(`图谱加载失败:${err.message}`);
- });
- function animate() {
- requestAnimationFrame(animate);
- controls.update();
- renderer.render(scene, camera);
- }
- animate();
- }
- function ensureDockPosition() {
- const workspace = chatDock.parentElement;
- const dockRect = chatDock.getBoundingClientRect();
- const workspaceRect = workspace.getBoundingClientRect();
- const left = dockRect.left - workspaceRect.left;
- const top = dockRect.top - workspaceRect.top;
- setDockPosition(left, top);
- return { left, top };
- }
- function endChatResize(e) {
- if (!resizeState) return;
- const handle = resizeState.handle;
- resizeState = null;
- if (e && handle && handle.hasPointerCapture && handle.hasPointerCapture(e.pointerId)) {
- handle.releasePointerCapture(e.pointerId);
- }
- }
- chatResizeHandles.forEach((handle) => {
- handle.addEventListener("pointerdown", (e) => {
- if (chatDock.classList.contains("collapsed")) return;
- const position = ensureDockPosition();
- resizeState = {
- handle,
- direction: handle.getAttribute("data-resize") || "",
- pointerId: e.pointerId,
- startX: e.clientX,
- startY: e.clientY,
- startLeft: position.left,
- startTop: position.top,
- startWidth: chatDock.offsetWidth,
- startHeight: chatDock.offsetHeight,
- };
- handle.setPointerCapture(e.pointerId);
- if (e.cancelable) e.preventDefault();
- });
- handle.addEventListener("pointermove", (e) => {
- if (!resizeState || resizeState.handle !== handle || e.pointerId !== resizeState.pointerId) return;
- if (e.cancelable) e.preventDefault();
- const workspace = chatDock.parentElement;
- const direction = resizeState.direction;
- const minWidth = 280;
- const minHeight = 320;
- let left = resizeState.startLeft;
- let top = resizeState.startTop;
- let width = resizeState.startWidth;
- let height = resizeState.startHeight;
- const dx = e.clientX - resizeState.startX;
- const dy = e.clientY - resizeState.startY;
- if (direction.includes("e")) {
- width = Math.min(workspace.clientWidth - resizeState.startLeft, Math.max(minWidth, resizeState.startWidth + dx));
- }
- if (direction.includes("w")) {
- width = Math.min(resizeState.startLeft + resizeState.startWidth, Math.max(minWidth, resizeState.startWidth - dx));
- left = resizeState.startLeft + resizeState.startWidth - width;
- }
- if (direction.includes("s")) {
- height = Math.min(workspace.clientHeight - resizeState.startTop, Math.max(minHeight, resizeState.startHeight + dy));
- }
- if (direction.includes("n")) {
- height = Math.min(resizeState.startTop + resizeState.startHeight, Math.max(minHeight, resizeState.startHeight - dy));
- top = resizeState.startTop + resizeState.startHeight - height;
- }
- chatDock.style.left = `${left}px`;
- chatDock.style.top = `${top}px`;
- chatDock.style.width = `${width}px`;
- chatDock.style.height = `${height}px`;
- });
- handle.addEventListener("pointerup", endChatResize);
- handle.addEventListener("pointercancel", endChatResize);
- handle.addEventListener("lostpointercapture", endChatResize);
- });
- function setDockPosition(left, top) {
- chatDock.style.left = `${left}px`;
- chatDock.style.top = `${top}px`;
- chatDock.style.right = "auto";
- chatDock.style.bottom = "auto";
- }
- function endChatDrag(e) {
- if (!chatDragState) return;
- chatDragState = null;
- if (e && chatHeader.hasPointerCapture && chatHeader.hasPointerCapture(e.pointerId)) {
- chatHeader.releasePointerCapture(e.pointerId);
- }
- }
- chatHeader.addEventListener("pointerdown", (e) => {
- if (e.target.closest("button")) return;
- const workspace = chatDock.parentElement;
- const dockRect = chatDock.getBoundingClientRect();
- const workspaceRect = workspace.getBoundingClientRect();
- const left = dockRect.left - workspaceRect.left;
- const top = dockRect.top - workspaceRect.top;
- setDockPosition(left, top);
- chatDragState = {
- pointerId: e.pointerId,
- startX: e.clientX,
- startY: e.clientY,
- originLeft: left,
- originTop: top,
- };
- chatHeader.setPointerCapture(e.pointerId);
- if (e.cancelable) e.preventDefault();
- });
- chatHeader.addEventListener("pointermove", (e) => {
- if (!chatDragState || e.pointerId !== chatDragState.pointerId) return;
- if (e.cancelable) e.preventDefault();
- const workspace = chatDock.parentElement;
- const maxLeft = Math.max(0, workspace.clientWidth - chatDock.offsetWidth);
- const maxTop = Math.max(0, workspace.clientHeight - chatDock.offsetHeight);
- const left = Math.min(maxLeft, Math.max(0, chatDragState.originLeft + (e.clientX - chatDragState.startX)));
- const top = Math.min(maxTop, Math.max(0, chatDragState.originTop + (e.clientY - chatDragState.startY)));
- chatDock.style.left = `${left}px`;
- chatDock.style.top = `${top}px`;
- });
- chatHeader.addEventListener("pointerup", endChatDrag);
- chatHeader.addEventListener("pointercancel", endChatDrag);
- chatHeader.addEventListener("lostpointercapture", endChatDrag);
- function setChatCollapsed(collapsed) {
- chatDock.classList.toggle("collapsed", collapsed);
- chatFab.classList.toggle("show", collapsed);
- chatToggle.textContent = collapsed ? "展开" : "收起";
- }
- chatToggle.addEventListener("click", () => {
- setChatCollapsed(!chatDock.classList.contains("collapsed"));
- });
- chatFab.addEventListener("click", () => {
- setChatCollapsed(false);
- });
- init();
- </script>
- <script src="data_quality.js?v=20260904"></script>
- </body>
- </html>
|