diff --git a/.dsh-plugins/pet-dock/lib/client.js b/.dsh-plugins/pet-dock/lib/client.js
new file mode 100644
index 0000000..f1f0ed9
--- /dev/null
+++ b/.dsh-plugins/pet-dock/lib/client.js
@@ -0,0 +1,496 @@
+// Native client bundle for @nex/pet-dock
+// Registered into shell.overlay + settings.section.
+// Plain CJS factory consumed by the browser ModuleLoader; only `react` (a platform seed) is required.
+window.__ModuleLoader__.load({
+ id: "@nex/pet-dock",
+ factory: (require) => {
+ var module = { exports: {} };
+ var exports = module.exports;
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
+ let react = require("react");
+
+ // ---- stylesheet -------------------------------------------------------
+ var cssId = "@nex/pet-dock/pet.css";
+ function claimStyles() {
+ if (typeof document === "undefined") return;
+ if (document.querySelector('style[data-plugin-css="' + cssId + '"]')) return;
+ var tag = document.createElement("style");
+ tag.dataset.plugin = "@nex/pet-dock";
+ tag.dataset.pluginCss = cssId;
+ tag.textContent = css;
+ document.head.appendChild(tag);
+ }
+
+ var css = `
+ .pet-float {
+ position: fixed;
+ top: 50%;
+ right: 14px;
+ transform: translateY(-50%);
+ z-index: 2147483000;
+ pointer-events: none;
+ font-family: var(--dsw-alias-font, system-ui, 'Segoe UI', sans-serif);
+ }
+ .pet-float * { box-sizing: border-box; }
+ .pet-outer {
+ pointer-events: auto;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 10px;
+ }
+ .pet-avatar {
+ width: 64px; height: 64px; border-radius: 50%; border: none; cursor: pointer;
+ display: flex; align-items: center; justify-content: center; font-size: 32px;
+ background: var(--dsw-alias-bg-overlay, #fff);
+ box-shadow: 0 4px 18px rgba(0,0,0,.18), inset 0 0 0 1px var(--dsw-alias-border-l1, rgba(0,0,0,.08));
+ animation: pet-bob 3s ease-in-out infinite;
+ }
+ @keyframes pet-bob { 0%,100%{transform:translateY(0);} 50%{transform:translateY(-6px);} }
+ .pet-window {
+ width: 220px;
+ background: var(--dsw-alias-bg-layer-1, #fff);
+ border-radius: 20px;
+ box-shadow: 0 18px 50px rgba(0,0,0,.26), inset 0 0 0 1px var(--dsw-alias-border-l1, rgba(0,0,0,.06));
+ overflow: hidden;
+ transform-origin: bottom right;
+ animation: pet-grow .3s cubic-bezier(.34,1.45,.64,1) both;
+ }
+ @keyframes pet-grow { 0%{opacity:0;transform:scale(.7) translateX(12px);} 100%{opacity:1;transform:scale(1) translateX(0);} }
+ .pet-head { display:flex; align-items:center; justify-content:space-between; padding:10px 12px 4px; }
+ .pet-name { font-weight:700; font-size:14px; color:var(--dsw-alias-label-primary,#222); }
+ .pet-name small { font-weight:500; color:var(--dsw-alias-label-secondary,#888); margin-left:6px; }
+ .pet-stage { height: 176px; display:flex; align-items:center; justify-content:space-between; padding:0 6px; position:relative; }
+ .pet-stage .stage-ground { position:absolute; bottom:12px; left:24%; right:24%; height:9px; border-radius:50%; background:radial-gradient(ellipse at center, rgba(0,0,0,.10), transparent 70%); }
+ .pet-stage .figure-wrap { position:absolute; left:0; right:0; top:0; bottom:0; display:flex; align-items:flex-end; justify-content:center; padding-bottom:18px; cursor:pointer; }
+ .pet-stage .figure-wrap:hover .pet-figure { transform: translateY(-6px) scale(1.06); }
+ .pet-stage .figure-wrap .tap-tip { position:absolute; top:4px; left:0; right:0; text-align:center; font-size:10px; color:var(--dsw-alias-label-secondary,#888); opacity:.75; pointer-events:none; }
+ .pet-arrow {
+ position:relative; z-index:2; width:30px; height:30px; border-radius:50%;
+ border:1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
+ background:var(--dsw-alias-bg-overlay,#fff); color:var(--dsw-alias-label-secondary,#888);
+ font-size:16px; line-height:1; cursor:pointer; display:flex; align-items:center; justify-content:center;
+ }
+ .pet-arrow:hover { background:var(--dsw-alias-bg-layer-2,#f3f4f8); color:var(--dsw-alias-brand-primary,#6c5ce7); transform:scale(1.1); }
+ .pet-status { text-align:center; font-size:11px; color:var(--dsw-alias-label-secondary,#888); padding:0 8px 10px; }
+ .pet-hint { text-align:center; font-size:10px; color:var(--dsw-alias-label-secondary,#888); padding:0 8px 8px; opacity:.7; }
+ .pet-figure { position:relative; transition:transform .25s ease; animation:body-bob 2.6s ease-in-out infinite; filter:drop-shadow(0 8px 10px rgba(0,0,0,.14)); }
+ @keyframes body-bob { 0%,100%{transform:translateY(0);} 50%{transform:translateY(-7px);} }
+ .p-eye { position:absolute; width:11px; height:11px; background:#222; border-radius:50%; animation:blink 4.2s steps(1) infinite; }
+ .p-eye::after { content:''; position:absolute; left:2px; top:1px; width:4px; height:4px; background:#fff; border-radius:50%; }
+ .p-eye.p-eyeB { background:#1e7a4d; }
+ @keyframes blink { 0%,88%,100%{transform:scaleY(1);} 92%,96%{transform:scaleY(.1);} }
+ @keyframes tail-wag { 0%,100%{transform:rotate(0);} 50%{transform:rotate(-16deg);} }
+ @keyframes dog-wag { 0%,100%{transform:rotate(-12deg);} 50%{transform:rotate(16deg);} }
+ @keyframes ear-flop { 0%,100%{transform:rotate(8deg);} 50%{transform:rotate(3deg);} }
+ @keyframes ear-flop-r { 0%,100%{transform:rotate(-8deg);} 50%{transform:rotate(-3deg);} }
+ .cow { position:relative; width:118px; height:122px; transform:scale(1.02); }
+ .cow-head { position:absolute; left:14px; top:14px; width:90px; height:82px; background:#fdfdfb; border-radius:46% 46% 40% 40%; box-shadow:inset 0 -10px 18px rgba(0,0,0,.05); }
+ .cow-patch { position:absolute; background:#3a3a42; border-radius:50%; }
+ .cow-patch.p1 { left:24px; top:5px; width:34px; height:22px; transform:rotate(12deg); }
+ .cow-patch.p2 { left:6px; top:34px; width:24px; height:17px; transform:rotate(-18deg); }
+ .cow-patch.p3 { right:12px; top:10px; width:17px; height:21px; transform:rotate(24deg); }
+ .cow-patch.p4 { right:2px; top:46px; width:20px; height:14px; background:#cfcfe0; transform:rotate(10deg); }
+ .cow-patch.p5 { left:9px; bottom:4px; width:17px; height:22px; background:#cfcfe0; transform:rotate(-22deg); }
+ .cow-ear { position:absolute; top:18px; width:24px; height:38px; background:#f0ede4; border-radius:50%; }
+ .cow-ear.l { left:1px; transform:rotate(-18deg); }
+ .cow-ear.r { right:1px; transform:rotate(18deg); }
+ .cow-ear::after { content:''; position:absolute; inset:6px 5px; background:#e8b7b0; border-radius:50%; }
+ .cow-horn { position:absolute; top:3px; width:0; height:0; border-left:9px solid transparent; border-right:9px solid transparent; border-bottom:19px solid #e4b983; }
+ .cow-horn.l { left:22px; transform:rotate(-24deg); }
+ .cow-horn.r { right:22px; transform:rotate(24deg); }
+ .cow-eye.l { left:20px; top:40px; z-index:2; }
+ .cow-eye.r { right:16px; top:42px; z-index:2; }
+ .cow-muzzle { position:absolute; left:25px; bottom:7px; width:40px; height:28px; background:#f2b6ad; border-radius:50%; }
+ .cow-muzzle::before, .cow-muzzle::after { content:''; position:absolute; top:8px; width:7px; height:9px; background:#d9837a; border-radius:50%; }
+ .cow-muzzle::before { left:8px; } .cow-muzzle::after { right:8px; }
+ .cow-body { position:absolute; left:14px; bottom:6px; width:90px; height:42px; background:#fdfdfb; border-radius:50% 50% 44% 44%; }
+ .cow-spot { position:absolute; background:#3a3a42; border-radius:50%; }
+ .cow-spot.s1 { left:2px; top:3px; width:21px; height:15px; transform:rotate(-14deg); }
+ .cow-spot.s2 { left:26px; top:2px; width:24px; height:15px; transform:rotate(8deg); }
+ .cow-spot.s3 { right:3px; top:8px; width:16px; height:15px; transform:rotate(-24deg); }
+ .cow-spot.s4 { left:11px; bottom:1px; width:15px; height:17px; background:#cfcfe0; transform:rotate(18deg); }
+ .cow-spot.s5 { right:13px; bottom:1px; width:17px; height:15px; background:#cfcfe0; transform:rotate(-8deg); }
+ .cow-leg { position:absolute; bottom:-8px; width:13px; height:21px; background:#efe7da; border-radius:4px; }
+ .cow-leg.l1 { left:16px; } .cow-leg.l2 { left:42px; }
+ .cow-leg.r1 { left:68px; } .cow-leg.r2 { left:92px; }
+ .cow-tail { position:absolute; right:2px; bottom:10px; width:34px; height:9px; border-radius:8px; background:#3a3a42; transform-origin:left center; animation:tail-wag 1.8s ease-in-out infinite; }
+ .cat { position:relative; width:112px; height:122px; transform:scale(1.04); }
+ .cat-head { position:absolute; left:16px; top:14px; width:80px; height:78px; background:linear-gradient(160deg,#ffb34d,#f79a35); border-radius:48% 48% 44% 44%; }
+ .cat-ear { position:absolute; top:0; width:0; height:0; border-left:15px solid transparent; border-right:15px solid transparent; border-bottom:28px solid #f79a35; }
+ .cat-ear.l { left:20px; transform:rotate(-14deg); }
+ .cat-ear.r { right:20px; transform:rotate(14deg); }
+ .cat-ear::after { content:''; position:absolute; left:-9px; top:11px; width:0; height:0; border-left:9px solid transparent; border-right:9px solid transparent; border-bottom:17px solid #ffc9d4; }
+ .cat-stripe { position:absolute; background:#e07f1f; border-radius:8px; }
+ .cat-stripe.s1 { left:24px; top:12px; width:7px; height:17px; transform:rotate(8deg); }
+ .cat-stripe.s2 { left:36px; top:9px; width:7px; height:19px; }
+ .cat-stripe.s3 { right:24px; top:12px; width:7px; height:17px; transform:rotate(-8deg); }
+ .cat-eye.l { left:22px; top:40px; background:#1e7a4d; }
+ .cat-eye.r { right:22px; top:40px; background:#1e7a4d; }
+ .cat-muzzle { position:absolute; left:29px; top:58px; width:22px; height:18px; background:#ffe9c9; border-radius:50%; }
+ .cat-nose { position:absolute; left:40px; top:52px; width:11px; height:7px; background:#f176a8; border-radius:50%; }
+ .cat-whisker { position:absolute; top:60px; width:20px; height:2px; background:rgba(0,0,0,.35); border-radius:2px; }
+ .cat-whisker.w1 { left:1px; transform:rotate(10deg); }
+ .cat-whisker.w2 { left:12px; top:68px; transform:rotate(-6deg); }
+ .cat-whisker.w3 { right:1px; transform:rotate(-10deg); }
+ .cat-whisker.w4 { right:12px; top:68px; transform:rotate(6deg); }
+ .cat-body { position:absolute; left:22px; bottom:7px; width:68px; height:42px; background:linear-gradient(160deg,#ffb34d,#f79a35); border-radius:48% 48% 42% 42%; }
+ .cat-belly { position:absolute; left:33px; bottom:7px; width:47px; height:36px; background:#fff3df; border-radius:48% 48% 42% 42%; }
+ .cat-paw { position:absolute; bottom:-5px; width:16px; height:13px; background:#f79a35; border-radius:50%; }
+ .cat-paw.p1 { left:27px; } .cat-paw.p2 { left:69px; }
+ .cat-tail { position:absolute; right:4px; bottom:8px; width:30px; height:11px; background:linear-gradient(180deg,#ffb34d 0%,#d9782b 100%); border-radius:8px 6px 6px 8px; transform-origin:left bottom; transform:rotate(-14deg); animation:tail-wag 2.2s ease-in-out infinite; }
+ .dog { position:relative; width:112px; height:124px; transform:scale(1.04); }
+ .dog-head { position:absolute; left:16px; top:16px; width:80px; height:76px; background:linear-gradient(160deg,#eebc83,#d99c5b); border-radius:48% 48% 44% 44%; }
+ .dog-ear { position:absolute; width:24px; height:36px; background:linear-gradient(180deg,#b9783d,#9c6230); border-radius:50% 50% 18px 18px; }
+ .dog-ear.l { left:6px; top:4px; transform-origin: top center; animation:ear-flop 2.6s ease-in-out infinite; }
+ .dog-ear.r { right:6px; top:4px; transform-origin: top center; animation:ear-flop-r 2.6s ease-in-out infinite; }
+ .dog-ear::after { content:''; position:absolute; left:7px; top:12px; width:10px; height:16px; background:#e8b6a0; border-radius:50%; }
+ .dog-snout { position:absolute; left:27px; top:56px; width:26px; height:18px; background:#f6e6cf; border-radius:50%; }
+ .dog-nose { position:absolute; left:39px; top:52px; width:13px; height:10px; background:#3a3028; border-radius:50%; }
+ .dog-mouth { position:absolute; left:37px; top:62px; width:16px; height:8px; border-bottom:2.5px solid #8a5a3a; border-radius:0 0 50% 50%; }
+ .dog-eye.l { left:30px; top:38px; }
+ .dog-eye.r { right:30px; top:38px; }
+ .dog-blush { position:absolute; top:50px; width:13px; height:8px; background:rgba(255,140,160,.5); border-radius:50%; }
+ .dog-blush.l { left:22px; } .dog-blush.r { right:22px; }
+ .dog-body { position:absolute; left:22px; bottom:7px; width:68px; height:44px; background:linear-gradient(160deg,#eebc83,#d99c5b); border-radius:48% 48% 42% 42%; }
+ .dog-belly { position:absolute; left:32px; bottom:7px; width:48px; height:36px; background:#f6e6cf; border-radius:48% 48% 42% 42%; }
+ .dog-paw { position:absolute; bottom:-5px; width:17px; height:14px; background:#d99c5b; border-radius:50%; }
+ .dog-paw.p1 { left:26px; } .dog-paw.p2 { left:69px; }
+ .dog-tail { position:absolute; right:3px; bottom:8px; width:30px; height:12px; background:linear-gradient(180deg,#eebc83 0%,#c08145 100%); border-radius:6px 7px 7px 6px; transform-origin:left bottom; transform:rotate(18deg); animation:dog-wag .8s ease-in-out infinite; }
+ .dog-tag { position:absolute; left:44px; bottom:16px; width:10px; height:10px; background:#ffd76a; border-radius:50%; box-shadow:0 0 0 2px #e6b84d; }
+ .pet-center { position:fixed; inset:0; z-index:2147483999; display:flex; align-items:center; justify-content:center; background:radial-gradient(120% 120% at 50% 40%, rgba(0,0,0,.10), transparent 70%); }
+ .pet-center .big-figure { cursor:pointer; filter:drop-shadow(0 16px 34px rgba(0,0,0,.28)); animation:center-in .55s cubic-bezier(.3,1.6,.5,1) both; }
+ .pet-center .big-figure.playing { animation:center-in .55s cubic-bezier(.3,1.6,.5,1) both, happy-dance 1.3s ease-in-out .4s infinite; }
+ .pet-center .big-figure.leaving { animation:center-out .55s cubic-bezier(.6,-0.1,.9,1) both; }
+ .pet-center .big-scale { transform:scale(calc(50vmin / 150px)); transform-origin:center; }
+ .pet-center .center-hint { position:absolute; bottom:14%; text-align:center; font-size:clamp(13px, 2.2vmin, 22px); font-weight:600; color:var(--dsw-alias-label-primary,#222); animation:hint-in .4s ease both; }
+ @keyframes center-in { 0%{opacity:0;transform:scale(.1);} 60%{transform:scale(1.15);} 100%{opacity:1;transform:scale(1);} }
+ @keyframes happy-dance { 0%,100%{transform:translateY(0) rotate(0) scale(1);} 25%{transform:translateY(-4%) rotate(-5deg) scale(1.02);} 50%{transform:translateY(0) rotate(0) scale(1);} 75%{transform:translateY(-4%) rotate(5deg) scale(1.02);} }
+ @keyframes center-out { 0%{opacity:1;transform:scale(1);} 100%{opacity:0;transform:scale(.06) translate(60vw, 0);} }
+ @keyframes hint-in { 0%{opacity:0;transform:translateY(8px);} 100%{opacity:1;transform:translateY(0);} }
+ /* ---- settings page ---- */
+ .pet-settings { display:flex; flex-direction:column; gap:12px; padding:4px 2px; }
+ .pet-settings-title { margin:0; font-size:16px; font-weight:600; color:var(--dsw-alias-label-primary,#222); }
+ .pet-settings-desc { margin:0; font-size:12px; color:var(--dsw-alias-label-secondary,#888); }
+ .pet-settings-row {
+ display:flex; align-items:center; gap:12px; padding:10px 12px;
+ border:1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
+ border-radius:12px; background:var(--dsw-alias-bg-layer-2,#f7f7fa);
+ }
+ .pet-settings-row.off { opacity:.55; }
+ .pet-settings-emoji { font-size:26px; flex:none; width:36px; text-align:center; }
+ .pet-settings-name {
+ flex:1; min-width:0; height:32px; border-radius:8px;
+ border:1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.14));
+ background:var(--dsw-alias-bg-overlay,#fff); color:var(--dsw-alias-label-primary,#222);
+ padding:0 10px; font-size:13px; outline:none;
+ }
+ .pet-settings-name:focus { border-color:var(--dsw-alias-brand-primary,#6c5ce7); }
+ .pet-settings-name:disabled { background:var(--dsw-alias-bg-layer-2,#f0f0f4); color:var(--dsw-alias-label-secondary,#999); }
+ .pet-settings-switch { display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--dsw-alias-label-secondary,#888); white-space:nowrap; }
+ .pet-settings-switch input { width:15px; height:15px; accent-color:var(--dsw-alias-brand-primary,#6c5ce7); cursor:pointer; }
+ `;
+
+ // ---- data ---------------------------------------------------------------
+ var STORAGE_KEY = 'pet-dock:config';
+ var DEFAULT_PETS = [
+ { kind: 'cow', name: '哞哞', emoji: '🐮', sub: '奶牛' },
+ { kind: 'cat', name: '喵喵', emoji: '🐱', sub: '小猫' },
+ { kind: 'dog', name: '汪汪', emoji: '🐶', sub: '小狗' },
+ ];
+
+ function defaultConfig() {
+ var cfg = { enabled: {}, names: {} };
+ DEFAULT_PETS.forEach(function (p) { cfg.enabled[p.kind] = true; cfg.names[p.kind] = p.name; });
+ return cfg;
+ }
+ function loadConfig() {
+ try {
+ var raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) {
+ var parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === 'object') return parsed;
+ }
+ } catch (e) {}
+ return defaultConfig();
+ }
+ function saveConfig(cfg) {
+ try { localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); } catch (e) {}
+ }
+ function activePets() {
+ var cfg = loadConfig();
+ return DEFAULT_PETS.filter(function (p) { return cfg.enabled && cfg.enabled[p.kind] !== false; })
+ .map(function (p) {
+ var name = (cfg.names && cfg.names[p.kind]) || p.name;
+ return { kind: p.kind, name: name, emoji: p.emoji, sub: p.sub };
+ });
+ }
+
+ // ---- shared timing helper (browser-native; avoids service timing issues) ---
+ function later(fn, ms) {
+ var id = setTimeout(fn, ms);
+ return function () { clearTimeout(id); };
+ }
+
+ function PetFigure(_a) {
+ var pet = _a.pet;
+ var el = react.createElement;
+ if (pet.kind === 'cow') {
+ return el('div', { className: 'cow' },
+ el('div', { className: 'cow-tail' }),
+ el('div', { className: 'cow-body' },
+ el('div', { className: 'cow-spot s1' }),
+ el('div', { className: 'cow-spot s2' }),
+ el('div', { className: 'cow-spot s3' }),
+ el('div', { className: 'cow-spot s4' }),
+ el('div', { className: 'cow-spot s5' })),
+ el('div', { className: 'cow-leg l1' }),
+ el('div', { className: 'cow-leg l2' }),
+ el('div', { className: 'cow-leg r1' }),
+ el('div', { className: 'cow-leg r2' }),
+ el('div', { className: 'cow-horn l' }),
+ el('div', { className: 'cow-horn r' }),
+ el('div', { className: 'cow-ear l' }),
+ el('div', { className: 'cow-ear r' }),
+ el('div', { className: 'cow-head' },
+ el('div', { className: 'cow-patch p1' }),
+ el('div', { className: 'cow-patch p2' }),
+ el('div', { className: 'cow-patch p3' }),
+ el('div', { className: 'cow-patch p4' }),
+ el('div', { className: 'cow-patch p5' }),
+ el('div', { className: 'cow-muzzle' }),
+ el('div', { className: 'p-eye cow-eye l' }),
+ el('div', { className: 'p-eye cow-eye r' })));
+ }
+ if (pet.kind === 'cat') {
+ return el('div', { className: 'cat' },
+ el('div', { className: 'cat-tail' }),
+ el('div', { className: 'cat-body' }),
+ el('div', { className: 'cat-belly' }),
+ el('div', { className: 'cat-paw p1' }),
+ el('div', { className: 'cat-paw p2' }),
+ el('div', { className: 'cat-ear l' }),
+ el('div', { className: 'cat-ear r' }),
+ el('div', { className: 'cat-head' },
+ el('div', { className: 'cat-stripe s1' }),
+ el('div', { className: 'cat-stripe s2' }),
+ el('div', { className: 'cat-stripe s3' }),
+ el('div', { className: 'p-eye cat-eye l' }),
+ el('div', { className: 'p-eye cat-eye r' }),
+ el('div', { className: 'cat-muzzle' }),
+ el('div', { className: 'cat-nose' }),
+ el('div', { className: 'cat-whisker w1' }),
+ el('div', { className: 'cat-whisker w2' }),
+ el('div', { className: 'cat-whisker w3' }),
+ el('div', { className: 'cat-whisker w4' })));
+ }
+ return el('div', { className: 'dog' },
+ el('div', { className: 'dog-tail' }),
+ el('div', { className: 'dog-body' }),
+ el('div', { className: 'dog-belly' }),
+ el('div', { className: 'dog-paw p1' }),
+ el('div', { className: 'dog-paw p2' }),
+ el('div', { className: 'dog-ear l' }),
+ el('div', { className: 'dog-ear r' }),
+ el('div', { className: 'dog-tag' }),
+ el('div', { className: 'dog-head' },
+ el('div', { className: 'p-eye dog-eye l' }),
+ el('div', { className: 'p-eye dog-eye r' }),
+ el('div', { className: 'dog-blush l' }),
+ el('div', { className: 'dog-blush r' }),
+ el('div', { className: 'dog-snout' }),
+ el('div', { className: 'dog-nose' }),
+ el('div', { className: 'dog-mouth' })));
+ }
+
+ function PetWindow(props) {
+ var el = react.createElement;
+ var pet = props.pet, onPrev = props.onPrev, onNext = props.onNext, onPlay = props.onPlay, disabled = props.disabled;
+ return el('div', { className: 'pet-window' },
+ el('div', { className: 'pet-head' },
+ el('div', { className: 'pet-name' },
+ pet.emoji + ' ' + pet.name,
+ el('small', null, pet.sub))),
+ el('div', { className: 'pet-stage' },
+ el('div', { className: 'stage-ground' }),
+ el('div', { className: 'figure-wrap', onClick: onPlay, title: '点击互动' },
+ el('div', { className: 'tap-tip' }, '点击我互动 ✨'),
+ el('div', { className: 'pet-figure' }, el(PetFigure, { pet: pet }))),
+ el('button', { className: 'pet-arrow', onClick: onPrev, title: '上一个', disabled: disabled }, '‹'),
+ el('button', { className: 'pet-arrow', onClick: onNext, title: '下一个', disabled: disabled }, '›')),
+ el('div', { className: 'pet-status' }, '正在努力陪着你工作…'),
+ el('div', { className: 'pet-hint' }, '点击宠物,放大到屏幕中央互动'));
+ }
+
+ function CenterReveal(props) {
+ var el = react.createElement;
+ var pet = props.pet, leaving = props.leaving, onExit = props.onExit;
+ return el('div', { className: 'pet-center', onClick: onExit },
+ el('div', {
+ className: 'big-figure' + (leaving ? ' leaving' : ' playing'),
+ onClick: onExit
+ },
+ el('div', { className: 'big-scale' }, el(PetFigure, { pet: pet }))),
+ el('div', { className: 'center-hint' },
+ leaving ? '回到窗口…' : pet.emoji + ' ' + pet.name + ' 来找你玩啦!点击返回'));
+ }
+
+ // ---- settings page ------------------------------------------------------
+ function SettingsView() {
+ var el = react.createElement;
+ var useState = react.useState;
+ var _a = useState(0), tick = _a[0], setTick = _a[1];
+ var useEffect = react.useEffect;
+ useEffect(function () {
+ function onChange() { setTick(function (t) { return t + 1; }); }
+ if (typeof window !== 'undefined' && window.addEventListener) {
+ window.addEventListener('pet-dock:config', onChange);
+ return function () { window.removeEventListener('pet-dock:config', onChange); };
+ }
+ }, []);
+ var cfg = loadConfig();
+ return el('div', { className: 'pet-settings' },
+ el('h3', { className: 'pet-settings-title' }, 'PetDock 宠物'),
+ el('p', { className: 'pet-settings-desc' }, '勾选要启用的形象,并为每只宠物自定义名字。'),
+ DEFAULT_PETS.map(function (p) {
+ var enabled = cfg.enabled && cfg.enabled[p.kind] !== false;
+ var name = (cfg.names && cfg.names[p.kind]) || p.name;
+ return el('div', { className: 'pet-settings-row' + (enabled ? '' : ' off'), key: p.kind },
+ el('span', { className: 'pet-settings-emoji' }, p.emoji),
+ el('input', {
+ className: 'pet-settings-name',
+ value: name,
+ disabled: !enabled,
+ placeholder: p.name,
+ onChange: function (e) {
+ var next = loadConfig();
+ next.names = next.names || {};
+ next.names[p.kind] = e.target.value;
+ saveConfig(next);
+ notifyConfig();
+ }
+ }),
+ el('label', { className: 'pet-settings-switch' },
+ el('input', {
+ type: 'checkbox',
+ checked: enabled,
+ onChange: function (e) {
+ var next = loadConfig();
+ next.enabled = next.enabled || {};
+ next.enabled[p.kind] = !!e.target.checked;
+ saveConfig(next);
+ notifyConfig();
+ }
+ }),
+ el('span', null, enabled ? '已启用' : '已停用')));
+ }));
+ }
+
+ function notifyConfig() {
+ if (typeof window !== 'undefined' && window.dispatchEvent) {
+ try { window.dispatchEvent(new Event('pet-dock:config')); } catch (e) {}
+ }
+ }
+
+ function apply(ctx) {
+ claimStyles();
+ var slots = ctx.slots;
+
+ slots.inject('settings.section', function () {
+ return slots.register(
+ { name: 'settings.section', id: 'pet-dock', order: 50, label: 'PetDock' },
+ SettingsView);
+ });
+
+ slots.inject('shell.overlay', function () {
+ return slots.register(
+ { name: 'shell.overlay', id: 'pet-dock' },
+ function () {
+ var useState = react.useState;
+ var useEffect = react.useEffect;
+ var _a = useState(0), index = _a[0], setIndex = _a[1];
+ var _b = useState(false), open = _b[0], setOpen = _b[1];
+ var _c = useState(false), switching = _c[0], setSwitching = _c[1];
+ var _d = useState(false), playing = _d[0], setPlaying = _d[1];
+ var _e = useState(false), leaving = _e[0], setLeaving = _e[1];
+ var _f = useState(0), tick = _f[0], setTick = _f[1];
+
+ useEffect(function () {
+ function onChange() { setTick(function (t) { return t + 1; }); }
+ if (typeof window !== 'undefined' && window.addEventListener) {
+ window.addEventListener('pet-dock:config', onChange);
+ return function () { window.removeEventListener('pet-dock:config', onChange); };
+ }
+ }, []);
+
+ var pets = activePets();
+ var safeIndex = pets.length === 0 ? 0 : (index >= pets.length ? 0 : index);
+ var pet = pets.length === 0 ? null : pets[safeIndex];
+ var single = pets.length <= 1;
+
+ var cycle = function (dir) {
+ if (playing || pets.length === 0) return;
+ setSwitching(true);
+ later(function () {
+ setIndex(function (i) {
+ if (pets.length === 0) return 0;
+ var base = i >= pets.length ? 0 : i;
+ return (base + dir + pets.length) % pets.length;
+ });
+ setSwitching(false);
+ setOpen(true);
+ }, 180);
+ };
+
+ var play = function () {
+ if (!pet || playing) return;
+ setPlaying(true);
+ setLeaving(false);
+ later(function () {
+ setLeaving(true);
+ later(function () {
+ setPlaying(false);
+ setLeaving(false);
+ setOpen(false);
+ }, 560);
+ }, 1900);
+ };
+
+ var exit = function () {
+ if (!playing) return;
+ setLeaving(true);
+ later(function () {
+ setPlaying(false);
+ setLeaving(false);
+ setOpen(false);
+ }, 560);
+ };
+
+ var el = react.createElement;
+ var dock = null;
+ if (pet) {
+ dock = el('div', {
+ className: 'pet-float',
+ onMouseEnter: function () { if (!playing) setOpen(true); },
+ onMouseLeave: function () { if (!playing) setOpen(false); }
+ },
+ el('div', { className: 'pet-outer' },
+ open
+ ? el(PetWindow, { pet: pet, switching: switching, disabled: single, onPrev: function () { return cycle(-1); }, onNext: function () { return cycle(1); }, onPlay: play })
+ : el('button', { className: 'pet-avatar', title: pet.name }, pet.emoji)));
+ }
+
+ var center = playing && pet
+ ? el(CenterReveal, { pet: pet, leaving: leaving, onExit: exit })
+ : null;
+
+ return el(react.Fragment, null, dock, center);
+ });
+ });
+ }
+
+ var inject = ["slots"];
+ module.exports = { apply: apply, inject: inject };
+ return module.exports;
+ }
+});
diff --git a/.dsh-plugins/pet-dock/lib/index.js b/.dsh-plugins/pet-dock/lib/index.js
new file mode 100644
index 0000000..3b39ba9
--- /dev/null
+++ b/.dsh-plugins/pet-dock/lib/index.js
@@ -0,0 +1,3 @@
+export function apply(ctx) {
+ // Host half is intentionally empty: this plugin only contributes browser UI.
+}
diff --git a/.dsh-plugins/pet-dock/package.json b/.dsh-plugins/pet-dock/package.json
new file mode 100644
index 0000000..4bcd341
--- /dev/null
+++ b/.dsh-plugins/pet-dock/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@nex/pet-dock",
+ "version": "0.1.0",
+ "description": "Digital pets dock: cow, cat & dog pets at the right-middle edge with click-to-center interaction.",
+ "type": "module",
+ "main": "lib/index.js",
+ "exports": {
+ ".": "./lib/index.js",
+ "./client": "./lib/client.js",
+ "./package.json": "./package.json"
+ },
+ "dsh": {
+ "client": {
+ "platform": "web",
+ "immediately": true
+ }
+ },
+ "license": "MIT"
+}
diff --git a/backend/app/api/v1/git_repos.py b/backend/app/api/v1/git_repos.py
index 619fec8..e9f9acc 100644
--- a/backend/app/api/v1/git_repos.py
+++ b/backend/app/api/v1/git_repos.py
@@ -9,7 +9,8 @@ from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.user import User
from app.models.git_repo import ProjectGitRepo
-from app.schemas.git_repo import GitRepoCreate, GitRepoUpdate, GitRepoResponse
+from app.schemas.git_repo import GitRepoCreate, GitRepoUpdate, GitRepoResponse, GitRepoTestRequest
+from app.services.git_service import git_service
from app.schemas.response import success_response
from app.services.log_service import log_service
from app.services.project_service import require_project_read_access, require_project_roles
@@ -86,7 +87,7 @@ async def create_git_repo(
username=repo_in.username,
token=repo_in.token,
is_default=is_default,
- sync_path=repo_in.sync_path or ""
+ sync_path=""
)
db.add(db_repo)
await db.commit()
@@ -95,6 +96,44 @@ async def create_git_repo(
return success_response(data=_repo_response(db_repo), message="Git仓库添加成功")
+@router.post("/projects/{project_id}/git-repos/test-connection", response_model=dict)
+async def test_git_repo_connection(
+ project_id: int,
+ repo_in: GitRepoTestRequest,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Test remote repository access without saving configuration or changing project files."""
+ await require_project_roles(
+ db,
+ project_id,
+ current_user,
+ allowed_roles=["admin", "editor"],
+ )
+
+ saved_repo = None
+ if repo_in.repo_id:
+ result = await db.execute(
+ select(ProjectGitRepo).where(
+ ProjectGitRepo.id == repo_in.repo_id,
+ ProjectGitRepo.project_id == project_id,
+ )
+ )
+ saved_repo = result.scalar_one_or_none()
+ if not saved_repo:
+ raise HTTPException(status_code=404, detail="仓库不存在")
+
+ repo_url = repo_in.repo_url or (saved_repo.repo_url if saved_repo else None)
+ branch = repo_in.branch or (saved_repo.branch if saved_repo else "main")
+ username = repo_in.username if repo_in.username is not None else (saved_repo.username if saved_repo else None)
+ token = repo_in.token or (saved_repo.token if saved_repo else None)
+
+ success, message = await git_service.test_connection(repo_url, branch, username, token)
+ if not success:
+ raise HTTPException(status_code=400, detail=f"远端仓库认证失败:{message}")
+ return success_response(message="远端仓库认证成功")
+
+
@router.put("/projects/{project_id}/git-repos/{repo_id}", response_model=dict)
async def update_git_repo(
project_id: int,
diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py
index d2156a4..570cbad 100644
--- a/backend/app/api/v1/projects.py
+++ b/backend/app/api/v1/projects.py
@@ -816,6 +816,10 @@ async def git_pull(
if not success:
raise HTTPException(status_code=500, detail=f"Git Pull失败: {msg}")
+ # 同步目录只在实际 Pull 成功后保存,避免设置阶段修改同步范围。
+ target_repo.sync_path = effective_sub_path
+ await db.commit()
+
# 记录日志
await log_service.log_project_operation(
db=db,
@@ -839,7 +843,10 @@ async def git_pull(
)
await db.commit()
- return success_response(message=f"Git Pull 成功 ({target_repo.name})")
+ return success_response(
+ data={"repo_id": target_repo.id, "sync_path": effective_sub_path},
+ message=f"Git Pull 成功 ({target_repo.name})",
+ )
@router.post("/{project_id}/git/push", response_model=dict)
@@ -912,6 +919,9 @@ async def git_push(
if not success:
raise HTTPException(status_code=500, detail=f"Git Push失败: {msg}")
+ # 同步目录只在实际 Push 成功后保存,空串表示整个仓库。
+ target_repo.sync_path = effective_sub_path
+
# 记录日志
await log_service.log_project_operation(
db=db,
@@ -922,7 +932,11 @@ async def git_push(
request=request,
)
- return success_response(message=f"Git Push 成功 ({target_repo.name})")
+ await db.commit()
+ return success_response(
+ data={"repo_id": target_repo.id, "sync_path": effective_sub_path},
+ message=f"Git Push 成功 ({target_repo.name})",
+ )
def _build_remote_dir_tree(paths):
diff --git a/backend/app/schemas/git_repo.py b/backend/app/schemas/git_repo.py
index da892c1..4136fb1 100644
--- a/backend/app/schemas/git_repo.py
+++ b/backend/app/schemas/git_repo.py
@@ -6,17 +6,16 @@ from typing import Optional
from datetime import datetime
-class GitRepoBase(BaseModel):
+class GitRepoConfigBase(BaseModel):
name: str = Field(..., max_length=50, description="仓库别名")
repo_url: str = Field(..., max_length=255, description="Git仓库地址")
branch: str = Field("main", max_length=50, description="Git分支")
username: Optional[str] = Field(None, description="Git用户名")
token: Optional[str] = Field(None, description="Git访问令牌")
is_default: int = Field(0, description="是否默认仓库")
- sync_path: str = Field("", max_length=255, description="同步目录(空=整个仓库)")
-class GitRepoCreate(GitRepoBase):
+class GitRepoCreate(GitRepoConfigBase):
"""创建Git仓库 Schema"""
pass
@@ -29,11 +28,20 @@ class GitRepoUpdate(BaseModel):
username: Optional[str] = None
token: Optional[str] = None
is_default: Optional[int] = None
- sync_path: Optional[str] = None
-class GitRepoResponse(GitRepoBase):
+class GitRepoTestRequest(BaseModel):
+ """Remote connection test payload. repo_id is used to reuse a saved token when editing."""
+ repo_id: Optional[int] = None
+ repo_url: Optional[str] = Field(None, max_length=255)
+ branch: Optional[str] = Field("main", max_length=50)
+ username: Optional[str] = None
+ token: Optional[str] = None
+
+
+class GitRepoResponse(GitRepoConfigBase):
"""Git仓库响应 Schema"""
+ sync_path: str = Field("", max_length=255, description="最近一次成功同步的目录")
id: int
project_id: int
created_at: datetime
diff --git a/backend/app/services/git_service.py b/backend/app/services/git_service.py
index ea60e24..0a8e370 100644
--- a/backend/app/services/git_service.py
+++ b/backend/app/services/git_service.py
@@ -23,8 +23,18 @@ class GitService:
return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, parsed.fragment))
def _auth_env(self, username: str = None, token: str = None) -> dict:
- """Pass HTTPS Basic credentials to Git for this process only."""
- env = {"GIT_TERMINAL_PROMPT": "0"}
+ """Pass HTTPS Basic credentials to Git for this process only.
+
+ 同时禁止交互式提示,避免凭证/主机密钥校验卡住进程:
+ - GIT_TERMINAL_PROMPT=0:禁止 Git 弹出用户名/密码输入
+ - GIT_ASKPASS=echo:凭证缺失时立即返回空串而不是挂起
+ - GIT_SSH_COMMAND:SSH 仓库走 BatchMode + 连接超时,不等待交互
+ """
+ env = {
+ "GIT_TERMINAL_PROMPT": "0",
+ "GIT_ASKPASS": "echo",
+ "GIT_SSH_COMMAND": "ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new",
+ }
if username and token:
credential = base64.b64encode(f"{username}:{token}".encode()).decode()
env.update({
@@ -263,6 +273,75 @@ class GitService:
"""Run push outside the async event loop."""
return await asyncio.to_thread(self._push, *args, **kwargs)
+ def _test_connection(
+ self, repo_url: str, branch: str = "main", username: str = None, token: str = None
+ ) -> Tuple[bool, str]:
+ """Verify remote access without changing the project working tree.
+
+ 通过 Git 配置让连接快速失败,避免错误配置(地址错误、认证失败、
+ 网络不可达)长时间挂起后仅返回"命令超时":
+ - http.connectTimeout:TCP/TLS 连接阶段超时(含 DNS)
+ - http.lowSpeedLimit/lowSpeedTime:连接后传输过慢视为失败
+ """
+ if not repo_url or not str(repo_url).strip():
+ return False, "仓库地址不能为空"
+
+ with tempfile.TemporaryDirectory(prefix="nex-docus-git-test-") as temp_path:
+ cwd = Path(temp_path)
+ ok, message = self._run_command(
+ [
+ "git",
+ "-c", "http.connectTimeout=10",
+ "-c", "http.lowSpeedLimit=1000",
+ "-c", "http.lowSpeedTime=15",
+ "ls-remote", "--heads", self._clean_repo_url(repo_url),
+ f"refs/heads/{branch or 'main'}",
+ ],
+ cwd,
+ self._auth_env(username, token),
+ timeout=30,
+ )
+ if ok:
+ return True, ""
+ return False, self._humanize_git_error(message)
+
+ def _humanize_git_error(self, message: str) -> str:
+ """把底层 Git/libcurl 报错翻译成对用户友好的提示。"""
+ if not message:
+ return "无法连接远端仓库"
+ lowered = message.lower()
+ if "超时" in message or "timed out" in lowered or "operation timed out" in lowered:
+ return "连接远端仓库超时,请检查仓库地址、网络或代理设置"
+ if "could not resolve host" in lowered or "name or service not known" in lowered:
+ return "无法解析仓库地址,请检查仓库 URL 是否正确"
+ if (
+ "connection refused" in lowered
+ or "connection reset" in lowered
+ or "couldn't connect" in lowered
+ or "could not connect" in lowered
+ or "failed to connect" in lowered
+ or "unable to access" in lowered
+ or "connection timed out" in lowered
+ ):
+ return "无法连接到远端仓库,请检查仓库地址、网络或代理设置"
+ if "authentication failed" in lowered or "401" in lowered or "403" in lowered:
+ return "认证失败,请检查用户名与访问令牌是否正确"
+ if "could not read username" in lowered or "terminal prompts disabled" in lowered:
+ return "未提供有效的用户名与访问令牌"
+ if "host key verification failed" in lowered:
+ return "SSH 主机密钥校验失败,请确认该主机已受信任"
+ if "permission denied" in lowered or "publickey" in lowered:
+ return "SSH 认证失败,请检查用户名与访问令牌"
+ if "repository not found" in lowered or "not found" in lowered or "does not exist" in lowered:
+ return "远端仓库不存在或无权访问"
+ return message
+
+ async def test_connection(
+ self, repo_url: str, branch: str = "main", username: str = None, token: str = None
+ ) -> Tuple[bool, str]:
+ """Run remote authentication test outside the async event loop."""
+ return await asyncio.to_thread(self._test_connection, repo_url, branch, username, token)
+
def list_remote_tree(
self, project_path: Path, repo_url: str, branch: str = "main", username: str = None,
token: str = None,
diff --git a/frontend/src/api/project.js b/frontend/src/api/project.js
index e24eef7..d85fb1b 100644
--- a/frontend/src/api/project.js
+++ b/frontend/src/api/project.js
@@ -156,7 +156,8 @@ export function gitPull(projectId, repoId = null, force = false, syncScope = nul
return request({
url: `/projects/${projectId}/git/pull`,
method: 'post',
- params: { repo_id: repoId, force, sync_scope: syncScope || undefined, sub_path: subPath || undefined }
+ params: { repo_id: repoId, force, sync_scope: syncScope || undefined, sub_path: subPath || undefined },
+ skipErrorToast: true,
})
}
@@ -167,7 +168,8 @@ export function gitPush(projectId, repoId = null, force = false, syncScope = nul
return request({
url: `/projects/${projectId}/git/push`,
method: 'post',
- params: { repo_id: repoId, force, sync_scope: syncScope || undefined, sub_path: subPath || undefined }
+ params: { repo_id: repoId, force, sync_scope: syncScope || undefined, sub_path: subPath || undefined },
+ skipErrorToast: true,
})
}
@@ -189,6 +191,7 @@ export function createGitRepo(projectId, data) {
url: `/projects/${projectId}/git-repos`,
method: 'post',
data,
+ skipErrorToast: true,
})
}
@@ -200,6 +203,7 @@ export function updateGitRepo(projectId, repoId, data) {
url: `/projects/${projectId}/git-repos/${repoId}`,
method: 'put',
data,
+ skipErrorToast: true,
})
}
@@ -210,6 +214,20 @@ export function deleteGitRepo(projectId, repoId) {
return request({
url: `/projects/${projectId}/git-repos/${repoId}`,
method: 'delete',
+ skipErrorToast: true,
+ })
+}
+
+/**
+ * 测试Git远端仓库认证
+ */
+export function testGitRepoConnection(projectId, data) {
+ return request({
+ url: `/projects/${projectId}/git-repos/test-connection`,
+ method: 'post',
+ data,
+ timeout: 35000,
+ skipErrorToast: true,
})
}
diff --git a/frontend/src/components/MainLayout/AppSider.jsx b/frontend/src/components/MainLayout/AppSider.jsx
index 3864f59..461cd07 100644
--- a/frontend/src/components/MainLayout/AppSider.jsx
+++ b/frontend/src/components/MainLayout/AppSider.jsx
@@ -20,8 +20,8 @@ import {
BookOutlined,
CommentOutlined,
} from '@ant-design/icons'
-import { message } from 'antd'
import { getUserMenus } from '@/api/menu'
+import Toast from '@/components/Toast/Toast'
import useUserStore from '@/stores/userStore'
import ModernSidebar from '../ModernSidebar/ModernSidebar'
@@ -194,7 +194,7 @@ function AppSider({ collapsed, onToggle }) {
}
} catch (error) {
console.error('Load menus error:', error)
- message.error('加载菜单失败')
+ Toast.error('加载菜单失败')
}
}
diff --git a/frontend/src/components/PDFViewer/PDFViewer.jsx b/frontend/src/components/PDFViewer/PDFViewer.jsx
index e33faaf..8160fd7 100644
--- a/frontend/src/components/PDFViewer/PDFViewer.jsx
+++ b/frontend/src/components/PDFViewer/PDFViewer.jsx
@@ -1,6 +1,7 @@
import { useState, useMemo } from 'react'
import { Document, Page, pdfjs } from 'react-pdf'
-import { Button, Space, InputNumber, message, Spin } from 'antd'
+import { Button, Space, InputNumber, Spin } from 'antd'
+import Toast from '@/components/Toast/Toast'
import {
LeftOutlined,
RightOutlined,
@@ -28,7 +29,7 @@ function PDFViewer({ url, filename }) {
}
const onDocumentLoadError = (error) => {
- message.error('PDF文件加载失败')
+ Toast.error('PDF文件加载失败')
}
const goToPrevPage = () => {
diff --git a/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx b/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx
index 9330494..798bf4c 100644
--- a/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx
+++ b/frontend/src/components/PDFViewer/VirtualPDFViewer.jsx
@@ -1,7 +1,8 @@
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { Document, Page, pdfjs } from 'react-pdf'
-import { Button, Space, InputNumber, message, Spin, Tooltip } from 'antd'
+import { Button, Space, InputNumber, Spin, Tooltip } from 'antd'
+import Toast from '@/components/Toast/Toast'
import {
ZoomInOutlined,
ZoomOutOutlined,
@@ -44,7 +45,7 @@ function VirtualPDFViewer({ url, filename, toolbarTarget, compactToolbar = false
const onDocumentLoadError = (error) => {
console.error('[PDF] Document load error:', error)
- message.error('PDF文件加载失败')
+ Toast.error('PDF文件加载失败')
}
diff --git a/frontend/src/components/ProjectFileTree/ProjectFileTree.css b/frontend/src/components/ProjectFileTree/ProjectFileTree.css
new file mode 100644
index 0000000..54fc771
--- /dev/null
+++ b/frontend/src/components/ProjectFileTree/ProjectFileTree.css
@@ -0,0 +1,125 @@
+/* ===== 统一文件树(浏览/编辑/分享三模式共用)===== */
+
+.file-tree-menu {
+ flex: 1;
+ padding: 8px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ border-right: none;
+ background: var(--sider-bg);
+ color: var(--text-color);
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-color) transparent;
+ /* 悬停行背景:明/暗主题分别取与侧栏背景有足够反差的浅色 */
+ --file-tree-hover-bg: rgba(0, 0, 0, 0.055);
+}
+body.dark .file-tree-menu {
+ --file-tree-hover-bg: rgba(255, 255, 255, 0.08);
+}
+
+/* 细滚动条 */
+.file-tree-menu::-webkit-scrollbar {
+ width: 6px;
+}
+.file-tree-menu::-webkit-scrollbar-track {
+ background: transparent;
+}
+.file-tree-menu::-webkit-scrollbar-thumb {
+ background: var(--border-color);
+ border-radius: 3px;
+}
+.file-tree-menu::-webkit-scrollbar-thumb:hover {
+ background: var(--text-color-secondary);
+}
+
+.file-tree-menu .ant-menu-item,
+.file-tree-menu .ant-menu-submenu-title {
+ overflow: hidden;
+ display: flex !important;
+ align-items: center;
+ min-width: 0;
+ border-radius: 6px;
+ transition: background-color 0.15s ease, color 0.15s ease;
+}
+
+.file-tree-menu .ant-menu-title-content {
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ overflow: hidden;
+}
+
+/* 统一标签:兼作右键菜单命中区域(编辑模式),三模式外观一致 */
+.file-tree-label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ min-width: 0;
+ user-select: none;
+ padding: 4px 8px;
+ margin: -4px -8px;
+ border-radius: 6px;
+ transition: color 0.15s ease;
+ color: var(--text-color);
+}
+
+.file-tree-label-text {
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.file-tree-share-icon {
+ color: var(--link-color);
+ font-size: 12px;
+ flex: none;
+ opacity: 0.9;
+}
+
+/* ===== 悬停状态(重设计):整行浅色高亮 + 文字变主题色 =====
+ 仅对未选中行生效,避免覆盖选中态 */
+.file-tree-menu .ant-menu-item:not(.ant-menu-item-selected):hover,
+.file-tree-menu .ant-menu-submenu:not(.file-tree-folder-selected) > .ant-menu-submenu-title:hover {
+ background-color: var(--file-tree-hover-bg) !important;
+}
+
+.file-tree-menu .ant-menu-item:not(.ant-menu-item-selected):hover .file-tree-label,
+.file-tree-menu .ant-menu-submenu:not(.file-tree-folder-selected) > .ant-menu-submenu-title:hover .file-tree-label {
+ color: var(--link-color) !important;
+}
+
+/* ===== 选中节点:背景、文字、图标统一高亮(选中态优先于悬停态) ===== */
+.file-tree-menu .ant-menu-item-selected,
+.file-tree-folder-selected > .ant-menu-submenu-title {
+ background-color: var(--selected-bg) !important;
+}
+
+.file-tree-menu .ant-menu-item-selected .file-tree-label,
+.file-tree-folder-selected > .ant-menu-submenu-title .file-tree-label,
+.file-tree-menu .ant-menu-submenu-selected > .ant-menu-submenu-title .file-tree-label {
+ color: var(--link-color) !important;
+ font-weight: 500;
+}
+
+.file-tree-menu .ant-menu-item-selected .ant-menu-item-icon,
+.file-tree-folder-selected > .ant-menu-submenu-title .ant-menu-item-icon,
+.file-tree-menu .ant-menu-submenu-selected > .ant-menu-submenu-title .ant-menu-item-icon {
+ color: var(--link-color) !important;
+}
+
+.file-tree-folder-selected > .ant-menu-submenu-title:hover {
+ background-color: var(--selected-bg) !important;
+}
+
+/* 空状态 */
+.file-tree-empty {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 32px 20px;
+ color: var(--text-color-secondary);
+}
diff --git a/frontend/src/components/ProjectFileTree/ProjectFileTree.jsx b/frontend/src/components/ProjectFileTree/ProjectFileTree.jsx
new file mode 100644
index 0000000..c31adef
--- /dev/null
+++ b/frontend/src/components/ProjectFileTree/ProjectFileTree.jsx
@@ -0,0 +1,220 @@
+import { useState, useMemo, useRef, useEffect } from 'react'
+import { Menu, Tooltip, Dropdown, Empty } from 'antd'
+import {
+ FolderOutlined,
+ FolderOpenOutlined,
+ FileTextOutlined,
+ FilePdfOutlined,
+ FileOutlined,
+ ShareAltOutlined,
+} from '@ant-design/icons'
+import './ProjectFileTree.css'
+
+/**
+ * 统一的项目文件树组件(浏览模式 / 编辑模式 / 分享模式共用)。
+ *
+ * 统一规则:
+ * - 文件夹:展开/收起图标,点击标题进入文件夹(onSelectFolder)
+ * - Markdown:FileTextOutlined,标题去掉 .md 后缀
+ * - PDF:FilePdfOutlined(红色),保留完整文件名
+ * - 其它文件:FileOutlined(showOtherFiles 时显示)
+ * - 已分享角标:ShareAltOutlined(showShareIcon 时显示)
+ * - 选中节点:背景 + 文字 + 图标统一高亮
+ * - 悬停提示:仅当名称显示不全(省略号)时才弹出 Tooltip
+ * - 切换文件夹:收拢其它展开目录,聚焦到新文件夹路径并滚动到可视区
+ *
+ * Props:
+ * - treeData: 树节点数组(可传过滤后的数据)
+ * - selectedKey: 当前选中的节点 key(高亮 + 自动聚焦)
+ * - openKeys / onOpenChange: 目录展开状态
+ * - onSelect: 菜单项点击(文件夹选中 / 文件打开)
+ * - onSelectFolder: 目录标题点击(onTitleClick)
+ * - getContextMenuItems: (node) => antd menu items | null,传入则启用右键菜单
+ * - collapseOnSelect: 切换文件夹时收拢其它目录(默认 true)
+ * - showShareIcon: 是否显示"已分享"角标(默认 false)
+ * - showOtherFiles: 是否显示非 md/pdf 文件(默认 false)
+ * - emptyText: 空状态文案
+ * - className: 追加到菜单根节点的 class
+ */
+
+// 按 key 查找树节点
+const findNodeByKey = (nodes, key) => {
+ for (const node of nodes) {
+ if (node.key === key) return node
+ if (node.children && node.children.length) {
+ const found = findNodeByKey(node.children, key)
+ if (found) return found
+ }
+ }
+ return null
+}
+
+// 计算路径的所有父级目录(不含自身),如 "a/b/c" -> ["a", "a/b"]
+const getAncestorPaths = (path) => {
+ const parts = path.split('/')
+ const ancestors = []
+ let current = ''
+ for (let i = 0; i < parts.length - 1; i++) {
+ current = current ? current + '/' + parts[i] : parts[i]
+ ancestors.push(current)
+ }
+ return ancestors
+}
+
+// 仅当文本被省略(显示不全)时才显示 Tooltip,避免冗余提示
+function OverflowTooltip({ title, className, children }) {
+ const textRef = useRef(null)
+ const [overflow, setOverflow] = useState(false)
+
+ useEffect(() => {
+ const el = textRef.current
+ if (!el) return
+ const check = () => setOverflow(el.scrollWidth > el.clientWidth + 1)
+ check()
+ if (typeof ResizeObserver !== 'undefined') {
+ const ro = new ResizeObserver(check)
+ ro.observe(el)
+ return () => ro.disconnect()
+ }
+ return undefined
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export default function ProjectFileTree({
+ treeData,
+ selectedKey,
+ openKeys,
+ onOpenChange,
+ onSelect,
+ onSelectFolder,
+ getContextMenuItems,
+ collapseOnSelect = true,
+ showShareIcon = false,
+ showOtherFiles = false,
+ emptyText = '暂无文档',
+ className = '',
+}) {
+ const menuRef = useRef(null)
+ const prevSelectedKeyRef = useRef(null)
+ const pendingFocusRef = useRef(false)
+
+ // 根据文件类型返回统一图标(PDF 红色、md 文本、其余通用)
+ const getNodeIcon = (node) => {
+ const lower = node.title.toLowerCase()
+ if (lower.endsWith('.pdf')) return
+ if (lower.endsWith('.md')) return
+ return
+ }
+
+ // 构建统一的行标签(省略检测 + 分享角标 + 可选右键菜单)
+ const buildLabel = (node) => {
+ const isPdf = node.title.toLowerCase().endsWith('.pdf')
+ const titleText = isPdf ? node.title : node.title.replace(/.md$/i, '')
+
+ const label = (
+
+ {titleText}
+ {showShareIcon && node.is_shared && (
+
+ )}
+
+ )
+
+ const contextItems = getContextMenuItems ? getContextMenuItems(node) : null
+ return contextItems && contextItems.length > 0
+ ? {label}
+ : label
+ }
+
+ const convertTreeToMenuItems = (nodes) => {
+ return nodes.map((node) => {
+ const label = buildLabel(node)
+
+ if (!node.isLeaf) {
+ const isOpen = openKeys.includes(node.key)
+ return {
+ key: node.key,
+ label,
+ icon: isOpen ? : ,
+ children: node.children ? convertTreeToMenuItems(node.children) : [],
+ className: selectedKey === node.key ? 'file-tree-folder-selected' : '',
+ onTitleClick: () => onSelectFolder(node.key),
+ }
+ }
+
+ const lower = node.title.toLowerCase()
+ const isOther = !lower.endsWith('.md') && !lower.endsWith('.pdf')
+ if (isOther && !showOtherFiles) return null
+
+ return { key: node.key, label, icon: getNodeIcon(node) }
+ }).filter(Boolean)
+ }
+
+ // 仅在影响渲染的输入变化时重建菜单项
+ const menuItems = useMemo(
+ () => convertTreeToMenuItems(treeData),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [treeData, selectedKey, openKeys, showShareIcon, showOtherFiles, getContextMenuItems]
+ )
+
+ // 切换选中项:标记待聚焦;若为文件夹且开启 collapseOnSelect,收拢其它目录
+ useEffect(() => {
+ const prev = prevSelectedKeyRef.current
+ prevSelectedKeyRef.current = selectedKey
+ if (!selectedKey || prev === selectedKey) return
+ pendingFocusRef.current = true
+
+ if (collapseOnSelect) {
+ const node = findNodeByKey(treeData, selectedKey)
+ if (node && !node.isLeaf) {
+ const ancestors = getAncestorPaths(selectedKey)
+ const next = [...ancestors]
+ // 保留选中文件夹自身的展开(如从文件夹视图下钻时 expandSelf)
+ if (openKeys.includes(selectedKey)) next.push(selectedKey)
+ if (next.join('|') !== openKeys.join('|')) {
+ onOpenChange?.(next)
+ }
+ }
+ }
+ }, [selectedKey, treeData, openKeys, collapseOnSelect, onOpenChange])
+
+ // 待聚焦时,等待 openKeys 应用后把选中节点滚动到可视区域
+ useEffect(() => {
+ if (!pendingFocusRef.current) return
+ pendingFocusRef.current = false
+ // rc-menu 的 ref 是 { list, focus, ... },根 DOM 在 .list 上
+ const root = menuRef.current && (menuRef.current.list || menuRef.current)
+ if (!root || typeof root.querySelector !== 'function') return
+ const el = root.querySelector(
+ '.ant-menu-item-selected, .ant-menu-submenu-selected > .ant-menu-submenu-title'
+ )
+ if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' })
+ }, [selectedKey, openKeys, menuItems])
+
+ if (menuItems.length === 0) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/pages/Chat/Chat.jsx b/frontend/src/pages/Chat/Chat.jsx
index d85b7f0..42db198 100644
--- a/frontend/src/pages/Chat/Chat.jsx
+++ b/frontend/src/pages/Chat/Chat.jsx
@@ -15,8 +15,8 @@ import {
Spin,
Tag,
Typography,
- message,
} from 'antd'
+import Toast from '@/components/Toast/Toast'
import {
FolderOutlined,
EditOutlined,
@@ -392,7 +392,7 @@ function MessageActions({ content, onCopy, onDelete, onRegenerate }) {
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
} else {
- message.error('复制失败')
+ Toast.error('复制失败')
}
onCopy?.(ok)
}
@@ -538,7 +538,7 @@ function Chat() {
const res = await getChatSessions()
setSessions(res.data || [])
} catch (error) {
- message.error('加载对话列表失败')
+ Toast.error('加载对话列表失败')
} finally {
setLoadingSessions(false)
}
@@ -607,7 +607,7 @@ function Chat() {
setMessages(nextMessages)
setCurrentSession(sessions.find((item) => item.session_id === id) || null)
} catch (error) {
- message.error('加载对话失败')
+ Toast.error('加载对话失败')
} finally {
setLoadingMessages(false)
}
@@ -685,15 +685,15 @@ function Chat() {
if (sending) return
const question = newQuestion.trim()
if (!question) {
- message.warning('请输入问题')
+ Toast.warning('请输入问题')
return
}
if (!newProjectId) {
- message.warning('请选择项目')
+ Toast.warning('请选择项目')
return
}
if (!newModelId) {
- message.warning('请选择模型')
+ Toast.warning('请选择模型')
return
}
const title = question.length > 24 ? `${question.slice(0, 24)}...` : question
@@ -703,7 +703,7 @@ function Chat() {
const res = await createChatSession(newProjectId, newModelId, title)
session = res.data
} catch (error) {
- message.error(error.response?.data?.detail || error.message || '新建对话失败')
+ Toast.error(error.response?.data?.detail || error.message || '新建对话失败')
setSending(false)
return
}
@@ -741,7 +741,7 @@ function Chat() {
})
touchSession(nextSession.session_id)
} catch (error) {
- message.error(error.response?.data?.detail || error.message || '对话已创建,但首条消息发送失败')
+ Toast.error(error.response?.data?.detail || error.message || '对话已创建,但首条消息发送失败')
} finally {
setSending(false)
}
@@ -758,7 +758,7 @@ function Chat() {
await sendMessageWithStream(currentSession, content)
touchSession(currentSession.session_id)
} catch (error) {
- message.error(error.response?.data?.detail || error.message || '发送消息失败')
+ Toast.error(error.response?.data?.detail || error.message || '发送消息失败')
} finally {
setSending(false)
}
@@ -780,7 +780,7 @@ function Chat() {
try {
await deleteChatMessage(item.id)
} catch (error) {
- message.error(error.response?.data?.detail || '删除旧回复失败')
+ Toast.error(error.response?.data?.detail || '删除旧回复失败')
return
}
}
@@ -797,7 +797,7 @@ function Chat() {
})
touchSession(currentSession.session_id)
} catch (error) {
- message.error(error.response?.data?.detail || error.message || '重新生成失败')
+ Toast.error(error.response?.data?.detail || error.message || '重新生成失败')
} finally {
setSending(false)
}
@@ -979,7 +979,7 @@ function Chat() {
await deleteChatMessage(item.id)
setMessages((prev) => prev.filter((m) => m.id !== item.id))
} catch (error) {
- message.error(error.response?.data?.detail || '删除失败')
+ Toast.error(error.response?.data?.detail || '删除失败')
}
},
})
@@ -1011,7 +1011,7 @@ function Chat() {
setHasSearched(true)
} catch (error) {
if (requestId === searchRequestIdRef.current) {
- message.error(error.response?.data?.detail || error.message || '搜索失败')
+ Toast.error(error.response?.data?.detail || error.message || '搜索失败')
}
} finally {
if (requestId === searchRequestIdRef.current) {
@@ -1040,7 +1040,7 @@ function Chat() {
const handleRenameSession = async () => {
const title = renameTitle.trim()
if (!renameSession || !title) {
- message.warning('请输入对话名称')
+ Toast.warning('请输入对话名称')
return
}
try {
@@ -1055,7 +1055,7 @@ function Chat() {
setRenameSession(null)
setRenameTitle('')
} catch (error) {
- message.error(error.response?.data?.detail || '重命名失败')
+ Toast.error(error.response?.data?.detail || '重命名失败')
}
}
@@ -1076,7 +1076,7 @@ function Chat() {
navigate('/chat', { replace: true })
}
} catch (error) {
- message.error(error.response?.data?.detail || '删除失败')
+ Toast.error(error.response?.data?.detail || '删除失败')
}
},
})
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index 9bf3956..526b155 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
-import { Card, Table, Spin, Button, message } from 'antd'
+import { Card, Table, Spin, Button } from 'antd'
import { UserOutlined, ProjectOutlined, FileTextOutlined, SyncOutlined, DashboardOutlined } from '@ant-design/icons'
import { getDashboardStats } from '@/api/dashboard'
import { rebuildIndex } from '@/api/search'
@@ -43,10 +43,10 @@ function Dashboard() {
setRebuilding(true)
try {
await rebuildIndex()
- message.success('索引重建任务已启动,请稍后搜索尝试')
+ Toast.success('索引重建任务已启动,请稍后搜索尝试')
} catch (error) {
console.error('Rebuild index error:', error)
- message.error('重建索引失败: ' + (error.response?.data?.detail || error.message))
+ Toast.error('重建索引失败: ' + (error.response?.data?.detail || error.message))
} finally {
setRebuilding(false)
}
diff --git a/frontend/src/pages/Document/DocumentEditor.css b/frontend/src/pages/Document/DocumentEditor.css
index 6b3946e..3c7985c 100644
--- a/frontend/src/pages/Document/DocumentEditor.css
+++ b/frontend/src/pages/Document/DocumentEditor.css
@@ -127,85 +127,6 @@
color: var(--link-color);
}
-.file-tree {
- padding: 8px;
- flex: 1;
- overflow-y: auto;
- overflow-x: hidden;
- background: var(--sider-bg);
- color: var(--text-color);
-}
-
-.file-tree .ant-menu-title-content {
- display: flex;
- align-items: center;
- min-width: 0;
- overflow: hidden;
-}
-
-/* Increase hit area for context menu */
-.tree-node-wrapper {
- width: 100%;
- height: 100%;
- display: flex;
- align-items: center;
- min-width: 0;
- user-select: none;
- padding: 4px 8px;
- margin: -4px -8px;
- border-radius: 4px;
- transition: all 0.2s;
- color: var(--text-color);
-}
-
-.tree-node-text {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* ===== 选中节点:文字、图标与背景同步高亮 ===== */
-
-/* 选中叶子节点(文件)的整行背景 */
-.file-tree .ant-menu-item-selected {
- background-color: var(--selected-bg) !important;
-}
-
-/* 选中文件夹标题的背景 */
-.file-tree .folder-selected>.ant-menu-submenu-title {
- background-color: var(--selected-bg) !important;
- color: var(--link-color) !important;
-}
-
-.file-tree .folder-selected>.ant-menu-submenu-title:hover {
- background-color: var(--selected-bg) !important;
-}
-
-/* 选中时文字颜色与字重(修复仅图标高亮、文字不变的问题) */
-.file-tree .ant-menu-item-selected .tree-node-wrapper,
-.file-tree .folder-selected>.ant-menu-submenu-title .tree-node-wrapper,
-.file-tree .ant-menu-submenu-selected>.ant-menu-submenu-title .tree-node-wrapper {
- color: var(--link-color) !important;
- font-weight: 500;
-}
-
-/* 选中时图标颜色(文件夹图标不再依赖内联 style,统一走主题色) */
-.file-tree .ant-menu-item-selected .ant-menu-item-icon,
-.file-tree .folder-selected>.ant-menu-submenu-title .ant-menu-item-icon,
-.file-tree .ant-menu-submenu-selected>.ant-menu-submenu-title .ant-menu-item-icon {
- color: var(--link-color) !important;
-}
-
-.file-tree .ant-menu-item,
-.file-tree .ant-menu-submenu-title {
- overflow: hidden;
- display: flex !important;
- align-items: center;
- min-width: 0;
-}
-
.document-content {
height: 100%;
width: 100%;
@@ -232,6 +153,7 @@
display: flex;
align-items: center;
justify-content: space-between;
+ gap: 16px;
background: var(--header-bg);
flex-shrink: 0;
}
@@ -283,6 +205,38 @@
color: var(--text-color-secondary);
}
+/* 文件夹视图滚动容器(复用浏览模式 docs-folder-* 布局,仅新增滚动) */
+.editor-folder-scroll {
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+ overflow-y: auto;
+ width: 100%;
+}
+
+/* PDF 只读预览(编辑模式下内置,与浏览模式一致) */
+.pdf-editor-container {
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ width: 100%;
+ height: 100%;
+ background: var(--bg-color);
+}
+
+.pdf-header-toolbar {
+ min-width: 0;
+ overflow-x: auto;
+ overflow-y: hidden;
+}
+
+.pdf-header-toolbar .pdf-toolbar {
+ white-space: nowrap;
+}
+
/* ByteMD 编辑器样式覆盖 */
.bytemd {
width: 100% !important;
diff --git a/frontend/src/pages/Document/DocumentEditor.jsx b/frontend/src/pages/Document/DocumentEditor.jsx
index 83ae353..78b1253 100644
--- a/frontend/src/pages/Document/DocumentEditor.jsx
+++ b/frontend/src/pages/Document/DocumentEditor.jsx
@@ -1,8 +1,7 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
-import { Layout, Menu, Button, Modal, Input, Space, Tooltip, Dropdown, Upload, Select, Progress, TreeSelect } from 'antd'
+import { Layout, Button, Modal, Input, Space, Tooltip, Upload, Select, Progress, TreeSelect, Empty } from 'antd'
import {
- FileOutlined,
FolderOutlined,
FolderOpenOutlined,
DeleteOutlined,
@@ -17,6 +16,7 @@ import {
FileTextOutlined,
UndoOutlined,
ArrowLeftOutlined,
+ ShareAltOutlined,
} from '@ant-design/icons'
import { Editor } from '@bytemd/react'
import gfm from '@bytemd/plugin-gfm'
@@ -40,9 +40,12 @@ import Toast from '@/components/Toast/Toast'
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
import { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
import LargeMarkdownEditor from '@/components/LargeMarkdownViewer/LargeMarkdownEditor'
+import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
+import ProjectFileTree from '@/components/ProjectFileTree/ProjectFileTree'
import './DocumentEditor.css'
const { Sider, Content } = Layout
+const ROOT_FOLDER_KEY = '__root__'
function DocumentEditor() {
const { projectId } = useParams()
@@ -70,6 +73,10 @@ function DocumentEditor() {
const [selectedMenuKey, setSelectedMenuKey] = useState(null) // 当前选中的菜单项(文件或文件夹)
const uploadingRef = useRef(false) // 使用ref防止重复上传
const [isPdfSelected, setIsPdfSelected] = useState(false) // 是否选中了PDF文件
+ const [pdfUrl, setPdfUrl] = useState('') // 当前 PDF 的访问地址(编辑模式内置只读预览)
+ const [pdfFilename, setPdfFilename] = useState('')
+ const [pdfToolbarTarget, setPdfToolbarTarget] = useState(null)
+ const [selectedFolderKey, setSelectedFolderKey] = useState(null) // 当前选中的文件夹(null=未选择文件夹,ROOT_FOLDER_KEY=根目录)
const [linkModalVisible, setLinkModalVisible] = useState(false)
const [linkTarget, setLinkTarget] = useState(null)
const [projectName, setProjectName] = useState('') // 项目名称
@@ -79,10 +86,6 @@ function DocumentEditor() {
const modeSwitchingRef = useRef(false)
const isLargeMarkdown = isLargeMarkdownContent(fileContent)
- const isHeaderPdf = selectedFile?.toLowerCase().endsWith('.pdf')
- const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined
- const headerLabel = selectedFile ? selectedFile.split('/').filter(Boolean).pop() : '请选择文件'
-
const linkTreeData = useMemo(() => {
const markSelectable = (nodes) => nodes.map((node) => {
if (node.children?.length) {
@@ -206,7 +209,7 @@ function DocumentEditor() {
downloadByUrl(buildDocumentUrl(path), getDownloadFilename(path))
}
- const selectFolder = (folderPath, { syncUrl = false } = {}) => {
+ const selectFolder = (folderPath, { syncUrl = false, expandSelf = false } = {}) => {
const targetNode = findNodeByKey(treeData, folderPath)
if (targetNode) {
setSelectedNode(targetNode)
@@ -215,17 +218,149 @@ function DocumentEditor() {
setSelectedFile(null)
setIsPdfSelected(false)
setFileContent('')
+ setPdfUrl('')
+ setPdfFilename('')
+ setSelectedFolderKey(folderPath)
+
+ // 自动展开父级目录,保证选中文件夹在左侧文件树中可见(与浏览模式一致)
+ expandParentFolders(`${folderPath}/placeholder`)
+
+ // 从内容区点击进入子文件夹时,同时展开自身,保证与左侧文件树同步
+ if (expandSelf) {
+ setOpenKeys(prev => (prev.includes(folderPath) ? prev : [...prev, folderPath]))
+ }
if (syncUrl) {
updateSelectedParam(folderPath, false)
}
}
+ const selectRootFolder = ({ syncUrl = true } = {}) => {
+ setSelectedNode(null)
+ setSelectedMenuKey(ROOT_FOLDER_KEY)
+ setSelectedFile(null)
+ setIsPdfSelected(false)
+ setFileContent('')
+ setPdfUrl('')
+ setPdfFilename('')
+ setSelectedFolderKey(ROOT_FOLDER_KEY)
+ setOpenKeys([])
+
+ if (syncUrl) {
+ const nextParams = new URLSearchParams(searchParams)
+ nextParams.delete('file')
+ nextParams.set('selected', ROOT_FOLDER_KEY)
+ setSearchParams(nextParams, { replace: true })
+ }
+ }
+
+ // 获取文件夹内的子节点(与左侧文件树保持一致:文件夹 + md/pdf 文档)
+ const getFolderChildren = (folderPath) => {
+ const node = folderPath === ROOT_FOLDER_KEY
+ ? { key: ROOT_FOLDER_KEY, title: projectName || '根目录', isLeaf: false, children: treeData }
+ : folderPath ? findNodeByKey(treeData, folderPath) : null
+ if (!node || node.isLeaf) return { folders: [], files: [], node }
+ const children = node.children || []
+ const folders = children.filter(c => !c.isLeaf)
+ const files = children.filter(c =>
+ c.isLeaf && (c.title.toLowerCase().endsWith('.md') || c.title.toLowerCase().endsWith('.pdf'))
+ )
+ return { folders, files, node }
+ }
+
+ const getBreadcrumbFolderPath = () => {
+ if (selectedFolderKey) {
+ return selectedFolderKey === ROOT_FOLDER_KEY ? ROOT_FOLDER_KEY : selectedFolderKey
+ }
+ const selectedPath = selectedFile || ''
+ const lastSlash = selectedPath.lastIndexOf('/')
+ return lastSlash >= 0 ? selectedPath.substring(0, lastSlash) : ROOT_FOLDER_KEY
+ }
+
+ const getBreadcrumbItems = () => {
+ const folderPath = getBreadcrumbFolderPath()
+ const folderParts = folderPath && folderPath !== ROOT_FOLDER_KEY
+ ? folderPath.split('/').filter(Boolean)
+ : []
+ const items = [{
+ key: ROOT_FOLDER_KEY,
+ label: projectName || '根目录',
+ icon: ,
+ onClick: () => selectRootFolder(),
+ }]
+
+ let currentPath = ''
+ folderParts.forEach((part) => {
+ currentPath = currentPath ? `${currentPath}/${part}` : part
+ const path = currentPath
+ items.push({
+ key: path,
+ label: part,
+ icon: ,
+ onClick: () => selectFolder(path, { syncUrl: true }),
+ })
+ })
+
+ // 打开具体文件时,末尾追加文件节点
+ if (!selectedFolderKey && selectedFile) {
+ const fileName = selectedFile.split('/').filter(Boolean).pop()
+ const isPdf = fileName.toLowerCase().endsWith('.pdf')
+ items.push({
+ key: selectedFile,
+ label: isPdf ? fileName : fileName.replace(/\.md$/i, ''),
+ icon: isPdf ? : ,
+ })
+ }
+ return items
+ }
+
+ const renderContentBreadcrumb = () => {
+ const items = getBreadcrumbItems()
+ const { folders, files } = getFolderChildren(getBreadcrumbFolderPath())
+
+ return (
+
+
+ {selectedFolderKey && (
+
+ {folders.length} 个文件夹 · {files.length} 个文档
+
+ )}
+
+ )
+ }
+
const getModeSwitchTarget = () => {
const params = new URLSearchParams()
if (selectedFile) {
params.set('file', selectedFile)
+ } else if (selectedFolderKey === ROOT_FOLDER_KEY) {
+ params.set('selected', ROOT_FOLDER_KEY)
} else if (selectedMenuKey) {
const targetNode = findNodeByKey(treeData, selectedMenuKey)
if (targetNode && !targetNode.isLeaf) {
@@ -355,6 +490,9 @@ function DocumentEditor() {
setSelectedFile(null)
setIsPdfSelected(false)
setFileContent('')
+ setPdfUrl('')
+ setPdfFilename('')
+ setSelectedFolderKey(key)
if (syncUrl) {
updateSelectedParam(key, false)
}
@@ -366,14 +504,21 @@ function DocumentEditor() {
}
expandParentFolders(key)
+ // PDF 文件:编辑模式也内置只读预览(与浏览模式一致),不弹"请切换浏览模式"
if (key.toLowerCase().endsWith('.pdf')) {
setSelectedFile(key)
setIsPdfSelected(true)
setFileContent('')
+ setPdfUrl(buildDocumentUrl(key))
+ setPdfFilename(key.split('/').pop())
+ setSelectedFolderKey(null)
return
}
setIsPdfSelected(false)
+ setPdfUrl('')
+ setPdfFilename('')
+ setSelectedFolderKey(null)
setLoading(true)
try {
const res = await getFileContent(projectId, key)
@@ -393,6 +538,12 @@ function DocumentEditor() {
const selectedParam = searchParams.get('selected')
if (selectedParam) {
+ if (selectedParam === ROOT_FOLDER_KEY) {
+ if (selectedFolderKey !== ROOT_FOLDER_KEY) {
+ selectRootFolder({ syncUrl: false })
+ }
+ return
+ }
const targetNode = findNodeByKey(treeData, selectedParam)
if (targetNode && !targetNode.isLeaf && selectedParam !== selectedMenuKey) {
selectFolder(selectedParam)
@@ -997,65 +1148,7 @@ function DocumentEditor() {
return items
}
- // 转换文件树为菜单项
- const convertTreeToMenuItems = (nodes) => {
- return nodes.map((node) => {
- // 使用Dropdown包裹label,实现右键菜单
- const isSelected = selectedMenuKey === node.key
- const labelContent = (
-
-
-
-
- {node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title}
-
-
-
-
- )
- if (!node.isLeaf) {
- const isOpen = openKeys.includes(node.key)
- // 目录 - 选中样式(背景/文字/图标)统一由 CSS 类控制,保证文字与图标同步高亮
- return {
- key: node.key,
- label: labelContent,
- icon: isOpen ? : ,
- children: node.children ? convertTreeToMenuItems(node.children) : [],
- className: isSelected ? 'folder-selected' : '',
- onTitleClick: () => {
- selectFolder(node.key, { syncUrl: true })
- },
- }
- } else if (node.title && node.title.endsWith('.md')) {
- // Markdown 文件
- return {
- key: node.key,
- label: labelContent,
- icon: ,
- }
- } else if (node.title && node.title.toLowerCase().endsWith('.pdf')) {
- // PDF 文件
- return {
- key: node.key,
- label: labelContent,
- icon: ,
- }
- } else {
- // 其他文件
- return {
- key: node.key,
- label: labelContent,
- icon: ,
- }
- }
- }).filter(Boolean)
- }
-
- const menuItems = convertTreeToMenuItems(treeData)
return (
@@ -1140,48 +1233,124 @@ function DocumentEditor() {
)}
-