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() {
)} - selectFolder(key, { syncUrl: true })} + getContextMenuItems={getNodeMenuItems} + showOtherFiles + emptyText="暂无文档" />
-

- - {headerLabel} -

- - - - + {renderContentBreadcrumb()} + {isPdfSelected ? ( + // PDF 只读预览,不提供保存/重置(工具栏由 VirtualPDFViewer 注入) +
+ ) : selectedFile ? ( + + + + + ) : null}
{isPdfSelected ? ( -
-

PDF文件请在浏览模式下查看

+
+
+ ) : selectedFolderKey ? ( + (() => { + const { folders, files, node } = getFolderChildren(selectedFolderKey) + if (!node || node.isLeaf) { + return ( +
+
+
+ + 已选择文件夹,请从左侧选择 Markdown 或 PDF 文件编辑。 +
+
+
+ ) + } + const total = folders.length + files.length + return ( +
+
+
+ {total === 0 ? ( + + ) : ( +
+ {folders.map(sub => ( +
selectFolder(sub.key, { syncUrl: true, expandSelf: true })} + title={sub.title} + > + + {sub.title} + {sub.children?.length || 0} 项 +
+ ))} + {files.map(file => { + const isPdf = file.title.toLowerCase().endsWith('.pdf') + const displayName = isPdf ? file.title : file.title.replace(/.md$/i, '') + return ( +
openNodeByKey(file.key, file, true)} + title={file.title} + > + {isPdf + ? + : } + + {displayName} + {file.is_shared && } + + {isPdf ? 'PDF 文档' : 'Markdown 文档'} +
+ ) + })} +
+ )} +
+
+
+ ) + })() ) : selectedFile ? (
{ - return nodes.map((node) => { - const titleText = node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title - const labelNode = ( - - - {titleText} - {node.is_shared && } - - - ) - - if (!node.isLeaf) { - const isOpen = openKeys.includes(node.key) - // 目录 - return { - key: node.key, - label: labelNode, - icon: isOpen ? : , - onTitleClick: () => selectFolder(node.key, { syncUrl: true }), - children: node.children ? convertTreeToMenuItems(node.children) : [], - } - } else if (node.title && node.title.endsWith('.md')) { - // Markdown 文件 - return { - key: node.key, - label: labelNode, - icon: , - } - } else if (node.title && node.title.endsWith('.pdf')) { - // PDF 文件 - return { - key: node.key, - label: labelNode, - icon: , - } - } - return null - }).filter(Boolean) - } - // 加载 markdown 文件 const loadMarkdown = async (filePath) => { setLoading(true) @@ -843,12 +802,20 @@ function DocumentPage() { const handleGitPull = async (repoId = null, force = false, syncScope = null, subPath = '') => { if (gitRepos.length === 0) { - message.warning('未配置Git仓库') + Toast.warning('未配置Git仓库') return false } try { const res = await gitPull(projectId, repoId, force, syncScope, subPath) - message.success(res.message || 'Git Pull 成功') + Toast.success(res.message || 'Git Pull 成功') + const syncData = res.data || {} + if (syncData.repo_id) { + setGitRepos((prev) => prev.map((repo) => ( + repo.id === syncData.repo_id + ? { ...repo, sync_path: syncData.sync_path || '' } + : repo + ))) + } // Refresh tree loadFileTree() // Reload current file if open @@ -883,19 +850,27 @@ function DocumentPage() { return false } - message.error(errorMsg) + Toast.error(errorMsg) return false } } const handleGitPush = async (repoId = null, force = false, syncScope = null, subPath = '') => { if (gitRepos.length === 0) { - message.warning('未配置Git仓库') + Toast.warning('未配置Git仓库') return false } try { const res = await gitPush(projectId, repoId, force, syncScope, subPath) - message.success(res.message || 'Git Push 成功') + Toast.success(res.message || 'Git Push 成功') + const syncData = res.data || {} + if (syncData.repo_id) { + setGitRepos((prev) => prev.map((repo) => ( + repo.id === syncData.repo_id + ? { ...repo, sync_path: syncData.sync_path || '' } + : repo + ))) + } return true } catch (error) { console.error('Git Push error:', error) @@ -923,7 +898,7 @@ function DocumentPage() { return false } - message.error(errorMsg) + Toast.error(errorMsg) return false } } @@ -1050,10 +1025,10 @@ function DocumentPage() { setRefreshing(true) try { await loadFileTree({ throwOnError: true }) - message.success('已刷新') + Toast.success('已刷新') } catch (error) { console.error('Refresh documents error:', error) - message.error('刷新失败') + Toast.error('刷新失败') } finally { setRefreshing(false) } @@ -1076,7 +1051,7 @@ function DocumentPage() { setShareModalVisible(true) } catch (error) { console.error('Get share info error:', error) - message.error('获取分享信息失败') + Toast.error('获取分享信息失败') } } @@ -1126,13 +1101,13 @@ function DocumentPage() { } setHasPassword(false) setPassword('') - message.success('已取消文件访问密码') + Toast.success('已取消文件访问密码') await loadFileTree() const res = await getFileShareInfo(projectId, selectedNodeKey) setShareInfo(res.data) } catch (error) { console.error('Update settings error:', error) - message.error('操作失败') + Toast.error('操作失败') } } else { setHasPassword(true) @@ -1142,12 +1117,12 @@ function DocumentPage() { // 保存密码 const handleSavePassword = async () => { if (!selectedNodeKey) { - message.warning('请先选择文件') + Toast.warning('请先选择文件') return } if (!password.trim()) { - message.warning('请输入访问密码') + Toast.warning('请输入访问密码') return } try { @@ -1155,7 +1130,7 @@ function DocumentPage() { file_path: selectedNodeKey, access_pass: hasPassword ? password : null, }) - message.success('文件分享已更新') + Toast.success('文件分享已更新') setShareInfo(res.data) await loadFileTree() const nextInfo = await getFileShareInfo(projectId, selectedNodeKey) @@ -1163,13 +1138,13 @@ function DocumentPage() { setHasPassword(Boolean(nextInfo.data?.has_password)) } catch (error) { console.error('Save password error:', error) - message.error('设置文件分享失败') + Toast.error('设置文件分享失败') } } const handleCreateShare = async () => { if (!selectedNodeKey) { - message.warning('请先选择文件') + Toast.warning('请先选择文件') return } @@ -1181,10 +1156,10 @@ function DocumentPage() { setShareInfo(res.data) setHasPassword(Boolean(res.data?.has_password)) await loadFileTree() - message.success('文件分享已创建') + Toast.success('文件分享已创建') } catch (error) { console.error('Create file share error:', error) - message.error('创建文件分享失败') + Toast.error('创建文件分享失败') } } @@ -1197,14 +1172,14 @@ function DocumentPage() { setHasPassword(false) setPassword('') await loadFileTree() - message.success('文件分享已关闭') + Toast.success('文件分享已关闭') } catch (error) { console.error('Delete file share error:', error) - message.error('关闭文件分享失败') + Toast.error('关闭文件分享失败') } } - const menuItems = convertTreeToMenuItems(filteredTreeData) + // Markdown 内容高亮处理 // 使用 components 替换文本节点,但这只对直接文本子节点有效 @@ -1295,6 +1270,7 @@ function DocumentPage() { > +

{projectName || '项目浏览'}

@@ -1351,21 +1327,16 @@ function DocumentPage() { />
- {filteredTreeData.length > 0 ? ( - - ) : ( -
- -
- )} + selectFolder(key, { syncUrl: true })} + showShareIcon + emptyText="未找到匹配文档" + /> {/* 右侧内容区 */} diff --git a/frontend/src/pages/Preview/PreviewPage.css b/frontend/src/pages/Preview/PreviewPage.css index f21a9fe..54eacc4 100644 --- a/frontend/src/pages/Preview/PreviewPage.css +++ b/frontend/src/pages/Preview/PreviewPage.css @@ -114,36 +114,6 @@ padding: 12px 16px 4px; } -.preview-menu { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - border-right: none; - background: var(--sider-bg); - color: var(--text-color); -} - -.preview-menu .ant-menu-item, -.preview-menu .ant-menu-submenu-title { - overflow: hidden; -} - -.preview-menu .ant-menu-title-content { - display: flex; - align-items: center; - min-width: 0; - overflow: hidden; -} - -.preview-menu-label { - display: block; - width: 100%; - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - .preview-content-layout { position: relative; height: 100%; @@ -276,6 +246,164 @@ min-height: 400px; } +.preview-breadcrumb-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + min-width: 0; + flex: 1; +} + +.preview-breadcrumb { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; + color: var(--text-color); + font-size: 16px; + font-weight: 600; +} + +.preview-breadcrumb-segment { + display: inline-flex; + align-items: center; + min-width: 0; + max-width: 100%; +} + +.preview-breadcrumb-link, +.preview-breadcrumb-current { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + padding: 4px 6px; + border: 0; + border-radius: 4px; + color: inherit; + font: inherit; + line-height: 1.4; + white-space: nowrap; +} + +.preview-breadcrumb-link { + background: transparent; + color: var(--text-color-secondary); + cursor: pointer; +} + +.preview-breadcrumb-link:hover { + background: var(--item-hover-bg); + color: var(--link-color); +} + +.preview-breadcrumb-link:focus-visible { + outline: 2px solid rgba(22, 119, 255, 0.35); + outline-offset: 1px; +} + +.preview-breadcrumb-current { + overflow: hidden; +} + +.preview-breadcrumb-current span { + overflow: hidden; + text-overflow: ellipsis; +} + +.preview-breadcrumb-separator { + margin: 0 2px; + color: var(--text-color-secondary); + font-weight: 400; +} + +.preview-folder-counts { + flex: none; + color: var(--text-color-secondary); + font-size: 14px; + font-weight: 400; + white-space: nowrap; +} + +/* ===== 文件夹内容视图 ===== */ +.preview-folder-placeholder { + min-height: 360px; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--text-color-secondary); + font-size: 14px; +} + +.preview-folder-view { + padding: 4px 0 24px; +} + +.preview-folder-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 14px; +} + +.preview-folder-item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding: 16px 18px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--card-bg); + cursor: pointer; + transition: all 0.2s ease; + min-width: 0; +} + +.preview-folder-item:hover { + border-color: var(--link-color); + box-shadow: var(--panel-shadow); + transform: translateY(-2px); +} + +.preview-folder-item:active { + transform: translateY(0); +} + +.preview-folder-item-icon { + font-size: 28px; + line-height: 1; + color: #faad14; +} + +.preview-folder-item-icon.md { + color: var(--link-color); +} + +.preview-folder-item-icon.pdf { + color: #f5222d; +} + +.preview-folder-item-name { + width: 100%; + display: inline-flex; + align-items: center; + gap: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + font-weight: 500; + color: var(--text-color); + min-width: 0; +} + +.preview-folder-item-count { + font-size: 12px; + color: var(--text-color-secondary); +} + .markdown-body { font-size: 16px; line-height: 1.6; @@ -402,6 +530,24 @@ gap: 8px; } + .preview-breadcrumb { + font-size: 14px; + } + + .preview-breadcrumb-link, + .preview-breadcrumb-current { + padding: 2px 4px; + } + + .preview-folder-counts { + display: none; + } + + .preview-folder-grid { + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px; + } + .file-share-content-header { gap: 8px; } diff --git a/frontend/src/pages/Preview/ProjectSharePage.jsx b/frontend/src/pages/Preview/ProjectSharePage.jsx index d6c305e..64ebfc2 100644 --- a/frontend/src/pages/Preview/ProjectSharePage.jsx +++ b/frontend/src/pages/Preview/ProjectSharePage.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useMemo } from 'react' import { useParams, useSearchParams, useNavigate } from 'react-router-dom' -import { Layout, Menu, Spin, Button, Modal, Input, Drawer, Empty, Tooltip, Space } from 'antd' +import { Layout, Spin, Button, Modal, Input, Drawer, Empty, Tooltip, Space } from 'antd' import { FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, UnorderedListOutlined, ArrowLeftOutlined } from '@ant-design/icons' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -13,6 +13,7 @@ import GithubSlugger from 'github-slugger' import Toast from '@/components/Toast/Toast' import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc' import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer' +import ProjectFileTree from '@/components/ProjectFileTree/ProjectFileTree' import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer' import { getProjectSharePublicInfo, @@ -26,6 +27,7 @@ import { import './PreviewPage.css' const { Sider, Content } = Layout +const ROOT_FOLDER_KEY = '__root__' const HighlightText = ({ text, keyword }) => { if (!keyword || !text) return text @@ -42,7 +44,7 @@ const HighlightText = ({ text, keyword }) => { function ProjectSharePage() { const { shareCode } = useParams() const navigate = useNavigate() - const [searchParams] = useSearchParams() + const [searchParams, setSearchParams] = useSearchParams() const [projectInfo, setProjectInfo] = useState(null) const [fileTree, setFileTree] = useState([]) const [selectedFile, setSelectedFile] = useState('') @@ -106,15 +108,34 @@ function ProjectSharePage() { if (fileTree.length === 0) return const fileParam = searchParams.get('file') - if (fileParam) { - openSharedFile(fileParam) - return - } + const selectedParam = searchParams.get('selected') - if (!selectedFile) { - const readmeNode = findReadme(fileTree) - if (readmeNode) { - openSharedFile(readmeNode.key) + // 文件夹导航(URL 带 selected 参数) + if (selectedParam) { + if (selectedParam === ROOT_FOLDER_KEY) { + if (selectedNodeKey !== ROOT_FOLDER_KEY) { + selectRootFolder({ syncUrl: false }) + } + return + } + const targetNode = findNodeByKey(fileTree, selectedParam) + if (targetNode && !targetNode.isLeaf && selectedParam !== selectedNodeKey) { + selectFolder(selectedParam) + } + } else if (fileParam) { + const targetNode = findNodeByKey(fileTree, fileParam) + if (targetNode && !targetNode.isLeaf) { + selectFolder(fileParam, { syncUrl: true }) + } else if (fileParam !== selectedFile) { + openSharedFile(fileParam, { syncUrl: false }) + } + } else { + // 没有指定文件时,默认打开根目录 README.md + if (!selectedFile && !selectedNodeKey) { + const readmeNode = findReadme(fileTree) + if (readmeNode) { + openSharedFile(readmeNode.key, { syncUrl: true }) + } } } }, [fileTree, searchParams]) @@ -264,6 +285,188 @@ function ProjectSharePage() { return null } + const findNodeByKey = (nodes, key) => { + for (const node of nodes) { + if (node.key === key) return node + if (node.children?.length) { + const found = findNodeByKey(node.children, key) + if (found) return found + } + } + return null + } + + const updateSelectedParam = (path, isFile = true) => { + const nextParams = new URLSearchParams(searchParams) + nextParams.delete('file') + nextParams.delete('selected') + if (path) { + nextParams.set(isFile ? 'file' : 'selected', path) + } + setSearchParams(nextParams, { replace: true }) + } + + const updateFileParam = (filePath) => { + updateSelectedParam(filePath, true) + } + + const expandParentFolders = (path) => { + const parts = path.split('/') + const allParentPaths = [] + let currentPath = '' + for (let i = 0; i < parts.length - 1; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i] + allParentPaths.push(currentPath) + } + if (allParentPaths.length > 0) { + setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])]) + } + } + + const selectFolder = (folderPath, { syncUrl = false, expandSelf = false } = {}) => { + setSelectedFile('') + setSelectedNodeKey(folderPath) + setMarkdownContent('') + setTocItems([]) + setViewMode('folder') + expandParentFolders(`${folderPath}/placeholder`) + + // 从内容区点击进入子文件夹时,同时展开自身,保证与左侧文件树同步 + if (expandSelf) { + setOpenKeys(prev => (prev.includes(folderPath) ? prev : [...prev, folderPath])) + } + + if (syncUrl) { + updateSelectedParam(folderPath, false) + } + } + + const selectRootFolder = ({ syncUrl = true } = {}) => { + setSelectedFile('') + setSelectedNodeKey(ROOT_FOLDER_KEY) + setMarkdownContent('') + setTocItems([]) + setViewMode('folder') + 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: projectInfo?.name || '项目分享', isLeaf: false, children: fileTree } + : folderPath ? findNodeByKey(fileTree, 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 (viewMode === 'folder') { + return selectedNodeKey === ROOT_FOLDER_KEY ? ROOT_FOLDER_KEY : selectedNodeKey + } + const selectedPath = selectedFile || selectedNodeKey || '' + 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: projectInfo?.name || '项目分享', + 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 (viewMode !== 'folder' && 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 ( +
+ + {viewMode === 'folder' && ( +
+ {folders.length} 个文件夹 · {files.length} 个文档 +
+ )} +
+ ) + } + + // 菜单点击:文件夹进入文件夹视图,文件打开文档 + const handleMenuClick = ({ key }) => { + const node = findNodeByKey(fileTree, key) + if (!node) return + if (!node.isLeaf) { + selectFolder(key, { syncUrl: true }) + } else { + openSharedFile(key, { syncUrl: true }) + } + } + const filteredTreeData = useMemo(() => { if (!searchKeyword.trim()) return fileTree @@ -288,34 +491,6 @@ function ProjectSharePage() { return loop(fileTree) }, [fileTree, searchKeyword, matchedFilePaths]) - const convertTreeToMenuItems = (nodes) => { - return nodes.map((node) => { - const titleText = node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title - const labelNode = ( - - {titleText} - - ) - if (!node.isLeaf) { - const isOpen = openKeys.includes(node.key) - return { - key: node.key, - label: labelNode, - icon: isOpen ? : , - onTitleClick: () => setSelectedNodeKey(node.key), - children: node.children ? convertTreeToMenuItems(node.children) : [], - } - } - if (node.title?.endsWith('.md')) { - return { key: node.key, label: labelNode, icon: } - } - if (node.title?.toLowerCase().endsWith('.pdf')) { - return { key: node.key, label: labelNode, icon: } - } - return null - }).filter(Boolean) - } - const resolveRelativePath = (currentPath, relativePath) => { if (relativePath.startsWith('/')) return relativePath.substring(1) const lastSlashIndex = currentPath.lastIndexOf('/') @@ -337,19 +512,13 @@ function ProjectSharePage() { return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath } - const openSharedFile = (key) => { + const openSharedFile = (key, { syncUrl = true } = {}) => { setSelectedFile(key) setSelectedNodeKey(key) + expandParentFolders(key) - const parts = key.split('/') - const allParentPaths = [] - let currentPath = '' - for (let i = 0; i < parts.length - 1; i++) { - currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i] - allParentPaths.push(currentPath) - } - if (allParentPaths.length > 0) { - setOpenKeys(prev => [...new Set([...prev, ...allParentPaths])]) + if (syncUrl) { + updateFileParam(key) } if (key.toLowerCase().endsWith('.pdf')) { @@ -399,11 +568,6 @@ function ProjectSharePage() { } } - const menuItems = useMemo( - () => convertTreeToMenuItems(filteredTreeData), - [filteredTreeData, openKeys] - ) - const markdownComponents = { a: ({ node, href, children, ...props }) => { const isExternal = isExternalHref(href) @@ -421,10 +585,6 @@ function ProjectSharePage() { }, } - const isHeaderPdf = selectedFile.toLowerCase().endsWith('.pdf') - const HeaderIcon = isHeaderPdf ? FilePdfOutlined : FileTextOutlined - const headerLabel = selectedFile ? selectedFile.split('/').filter(Boolean).pop() : 'README.md' - return (
@@ -448,22 +608,21 @@ function ProjectSharePage() { enterButton />
- {menuItems.length > 0 ? ( - { - openSharedFile(key) - setMobileDrawerVisible(false) - }} - className="preview-menu" - /> - ) : ( - - )} + { + handleMenuClick(info) + setMobileDrawerVisible(false) + }} + onSelectFolder={(key) => { + selectFolder(key, { syncUrl: true }) + setMobileDrawerVisible(false) + }} + emptyText="暂无文档" + /> ) : ( @@ -492,21 +651,15 @@ function ProjectSharePage() { enterButton />
- {menuItems.length > 0 ? ( - openSharedFile(key)} - className="preview-menu" - /> - ) : ( -
- -
- )} + selectFolder(key, { syncUrl: true })} + emptyText="暂无文档" + /> )} @@ -535,10 +688,7 @@ function ProjectSharePage() {
)} -

- - {headerLabel} -

+ {renderContentBreadcrumb()} {viewMode === 'markdown' && ( isMobile ? ( @@ -602,6 +752,63 @@ function ProjectSharePage() {
) : viewMode === 'pdf' ? ( + ) : viewMode === 'folder' ? ( + (() => { + const { folders, files, node } = getFolderChildren(selectedNodeKey) + if (!node || node.isLeaf) { + return ( +
+ + 已选择文件夹,请从左侧选择 Markdown 或 PDF 文件查看。 +
+ ) + } + const total = folders.length + files.length + return ( +
+ {total === 0 ? ( + + ) : ( +
+ {folders.map(sub => ( +
selectFolder(sub.key, { syncUrl: true, expandSelf: true })} + title={sub.title} + > + + {sub.title} + {sub.children?.length || 0} 项 +
+ ))} + {files.map(file => { + const isPdf = file.title.toLowerCase().endsWith('.pdf') + const displayName = isPdf ? file.title : file.title.replace(/.md$/i, '') + return ( +
openSharedFile(file.key, { syncUrl: true })} + title={file.title} + > + {isPdf + ? + : } + {displayName} + {isPdf ? 'PDF 文档' : 'Markdown 文档'} +
+ ) + })} +
+ )} +
+ ) + })() ) : isLargeMarkdown ? ( u.id !== currentProject.owner_id)) } catch (error) { - message.error('加载用户列表失败') + Toast.error('加载用户列表失败') } finally { setLoadingMembers(false) } @@ -161,14 +160,14 @@ function ProjectList({ type = 'my' }) { onOk: async () => { try { await transferProject(currentProject.id, values.new_owner_id) - message.success('项目所有权已转移') + Toast.success('项目所有权已转移') setTransferModalVisible(false) setEditModalVisible(false) transferForm.resetFields() fetchProjects() } catch (error) { console.error('Transfer error:', error) - message.error('转移失败: ' + (error.response?.data?.detail || error.message)) + Toast.error('转移失败: ' + (error.response?.data?.detail || error.message)) } } }) @@ -260,7 +259,7 @@ function ProjectList({ type = 'my' }) { is_public: checked ? 1 : 0, } setCurrentProject(updatedProject) - message.success(checked ? '项目已设为公开' : '项目已设为私有') + Toast.success(checked ? '项目已设为公开' : '项目已设为私有') await fetchProjects() if (checked) { @@ -275,7 +274,7 @@ function ProjectList({ type = 'my' }) { } } catch (error) { console.error('Toggle public error:', error) - message.error('操作失败') + Toast.error('操作失败') } } @@ -291,13 +290,13 @@ function ProjectList({ type = 'my' }) { ...values, } setCurrentProject(updatedProject) - message.success('项目更新成功') + Toast.success('项目更新成功') await fetchProjects() setEditModalVisible(false) editForm.resetFields() } catch (error) { console.error('Update project error:', error) - message.error('项目更新失败') + Toast.error('项目更新失败') } } @@ -327,44 +326,9 @@ function ProjectList({ type = 'my' }) { const [loadingRepos, setLoadingRepos] = useState(false) const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false) const [editingRepo, setEditingRepo] = useState(null) - const [gitDirOptions, setGitDirOptions] = useState([]) + const [testingGitRepo, setTestingGitRepo] = useState(false) const [repoForm] = Form.useForm() - // 构建本地项目目录树选项(仅文件夹,回退用) - const buildGitDirOptions = (nodes, prefix = '') => { - const options = [] - for (const node of nodes || []) { - if (!node.isLeaf) { - options.push({ - title: `${prefix}${node.title}`, - value: node.key, - children: node.children ? buildGitDirOptions(node.children, `${prefix}${node.title}/`) : [], - }) - } - } - return options - } - - // 先加载本地目录,避免打开配置时因远端网络请求卡住界面。 - const loadLocalGitDirOptions = (projectId) => { - return getProjectTree(projectId) - .then((res) => { - const tree = res.data?.tree || res.data || [] - setGitDirOptions(buildGitDirOptions(tree)) - }) - .catch(() => setGitDirOptions([])) - } - - // 只有编辑同步目录时才按需查询远端目录;失败时保留本地目录选项。 - const loadRemoteGitDirOptions = (projectId, repoId = null) => { - getGitRepoDirectories(projectId, repoId) - .then((res) => { - const opts = res.data?.directories || [] - if (opts.length) setGitDirOptions(opts) - }) - .catch(() => {}) - } - // 打开Git设置(仓库列表) const handleGitSettings = (e, project) => { e.stopPropagation() @@ -388,7 +352,7 @@ function ProjectList({ type = 'my' }) { setGitRepos(res.data || []) } catch (error) { console.error('Fetch git repos error:', error) - message.error('加载Git仓库失败') + Toast.error('加载Git仓库失败') } finally { setLoadingRepos(false) } @@ -398,15 +362,11 @@ function ProjectList({ type = 'my' }) { const handleAddRepo = () => { setEditingRepo(null) repoForm.resetFields() - setGitDirOptions([]) // 如果是第一个仓库,默认设为默认 if (gitRepos.length === 0) { repoForm.setFieldsValue({ is_default: 1 }) } setGitRepoModalVisible(true) - if (currentProject) { - loadLocalGitDirOptions(currentProject.id) - } } // 打开编辑仓库弹窗 @@ -415,13 +375,25 @@ function ProjectList({ type = 'my' }) { repoForm.setFieldsValue({ ...repo, is_default: repo.is_default === 1, - sync_path: repo.sync_path || undefined, }) setGitRepoModalVisible(true) - // 先用本地目录立即渲染,再后台按需加载远端目录结构。 - if (currentProject) { - loadLocalGitDirOptions(currentProject.id) - .then(() => loadRemoteGitDirOptions(currentProject.id, repo.id)) + } + + const handleTestRepoConnection = async () => { + try { + const values = await repoForm.validateFields(['repo_url', 'branch', 'username', 'token']) + setTestingGitRepo(true) + await testGitRepoConnection(currentProject.id, { + ...values, + repo_id: editingRepo?.id, + }) + Toast.success('远端仓库认证成功') + } catch (error) { + if (error?.errorFields) return + const errorMsg = error.response?.data?.detail || '远端仓库认证失败' + Toast.error(errorMsg) + } finally { + setTestingGitRepo(false) } } @@ -433,11 +405,11 @@ function ProjectList({ type = 'my' }) { onOk: async () => { try { await deleteGitRepo(currentProject.id, repoId) - message.success('删除成功') + Toast.success('删除成功') fetchGitRepos(currentProject.id) } catch (error) { console.error('Delete repo error:', error) - message.error('删除失败') + Toast.error(error.response?.data?.detail || '删除失败') } }, }) @@ -449,22 +421,21 @@ function ProjectList({ type = 'my' }) { const data = { ...values, is_default: values.is_default ? 1 : 0, - sync_path: values.sync_path || '', } if (editingRepo) { await updateGitRepo(currentProject.id, editingRepo.id, data) - message.success('更新成功') + Toast.success('更新成功') } else { await createGitRepo(currentProject.id, data) - message.success('添加成功') + Toast.success('添加成功') } setGitRepoModalVisible(false) fetchGitRepos(currentProject.id) } catch (error) { console.error('Save repo error:', error) - message.error(editingRepo ? '更新失败' : '添加失败') + Toast.error(error.response?.data?.detail || (editingRepo ? '更新失败' : '添加失败')) } } @@ -526,12 +497,12 @@ function ProjectList({ type = 'my' }) { await updateProjectShareSettings(currentProject.id, { access_pass: null }) setHasPassword(false) setPassword('') - message.success('已取消访问密码') + Toast.success('已取消访问密码') const res = await getProjectShareInfo(currentProject.id) setShareInfo(res.data) } catch (error) { console.error('Update settings error:', error) - message.error('操作失败') + Toast.error('操作失败') } } else { setHasPassword(true) @@ -541,18 +512,18 @@ function ProjectList({ type = 'my' }) { // 保存密码 const handleSavePassword = async () => { if (!password.trim()) { - message.warning('请输入访问密码') + Toast.warning('请输入访问密码') return } try { await updateProjectShareSettings(currentProject.id, { access_pass: password }) - message.success('访问密码已设置') + Toast.success('访问密码已设置') const res = await getProjectShareInfo(currentProject.id) setShareInfo(res.data) setHasPassword(true) } catch (error) { console.error('Save password error:', error) - message.error('设置密码失败') + Toast.error('设置密码失败') } } @@ -574,7 +545,7 @@ function ProjectList({ type = 'my' }) { console.log('Users Response:', usersRes) const membersData = membersRes.data || [] - // 后端返回格式: { code: 200, message: "success", data: [...], total, page, page_size } + // 后端返回格式: { code: 200: "success", data: [...], total, page, page_size } const usersData = Array.isArray(usersRes.data) ? usersRes.data : [] console.log('Setting members:', membersData) @@ -585,7 +556,7 @@ function ProjectList({ type = 'my' }) { } catch (error) { console.error('Get members error:', error) console.error('Error details:', error.response) - message.error('获取数据失败: ' + (error.response?.data?.detail || error.message)) + Toast.error('获取数据失败: ' + (error.response?.data?.detail || error.message)) } finally { setLoadingMembers(false) } @@ -595,7 +566,7 @@ function ProjectList({ type = 'my' }) { const handleAddMember = async (values) => { try { await addProjectMember(currentProject.id, values) - message.success('成员添加成功') + Toast.success('成员添加成功') memberForm.resetFields() // 刷新成员列表(带用户名信息) const res = await getProjectMembers(currentProject.id) @@ -603,7 +574,7 @@ function ProjectList({ type = 'my' }) { } catch (error) { console.error('Add member error:', error) const errorMsg = error.response?.data?.detail || error.message || '添加成员失败' - message.error(errorMsg) + Toast.error(errorMsg) } } @@ -615,14 +586,14 @@ function ProjectList({ type = 'my' }) { onOk: async () => { try { await removeProjectMember(currentProject.id, userId) - message.success('成员删除成功') + Toast.success('成员删除成功') // 刷新成员列表(带用户名信息) const res = await getProjectMembers(currentProject.id) setMembers(res.data || []) } catch (error) { console.error('Remove member error:', error) const errorMsg = error.response?.data?.detail || error.message || '删除成员失败' - message.error(errorMsg) + Toast.error(errorMsg) } }, }) @@ -1340,25 +1311,6 @@ function ProjectList({ type = 'my' }) { - - - String(treeNode.title || '').toLowerCase().includes(inputValue.toLowerCase()) - } - style={{ width: '100%' }} - /> - - + diff --git a/frontend/src/pages/ProjectList/useProjectCollaboration.js b/frontend/src/pages/ProjectList/useProjectCollaboration.js index 4f857e4..96c1f62 100644 --- a/frontend/src/pages/ProjectList/useProjectCollaboration.js +++ b/frontend/src/pages/ProjectList/useProjectCollaboration.js @@ -1,5 +1,6 @@ import { useState } from 'react' -import { message, Modal } from 'antd' +import { Modal } from 'antd' +import Toast from '@/components/Toast/Toast' import { addProjectMember, getProjectMembers, removeProjectMember, transferProject } from '@/api/project' import { getUserList } from '@/api/users' @@ -18,7 +19,7 @@ function useProjectCollaboration() { const allUsers = res.data || [] setUsers(allUsers.filter((user) => user.id !== project.owner_id)) } catch (error) { - message.error('加载用户列表失败') + Toast.error('加载用户列表失败') } finally { setLoadingMembers(false) } @@ -33,12 +34,12 @@ function useProjectCollaboration() { onOk: async () => { try { await transferProject(projectId, newOwnerId) - message.success('项目所有权已转移') + Toast.success('项目所有权已转移') setTransferModalVisible(false) onCompleted?.() } catch (error) { console.error('Transfer error:', error) - message.error(`转移失败: ${error.response?.data?.detail || error.message}`) + Toast.error(`转移失败: ${error.response?.data?.detail || error.message}`) } }, }) @@ -63,7 +64,7 @@ function useProjectCollaboration() { } catch (error) { console.error('Get members error:', error) console.error('Error details:', error.response) - message.error(`获取数据失败: ${error.response?.data?.detail || error.message}`) + Toast.error(`获取数据失败: ${error.response?.data?.detail || error.message}`) } finally { setLoadingMembers(false) } @@ -78,13 +79,13 @@ function useProjectCollaboration() { const addMember = async (projectId, values) => { try { await addProjectMember(projectId, values) - message.success('成员添加成功') + Toast.success('成员添加成功') const res = await getProjectMembers(projectId) setMembers(res.data || []) } catch (error) { console.error('Add member error:', error) const errorMsg = error.response?.data?.detail || error.message || '添加成员失败' - message.error(errorMsg) + Toast.error(errorMsg) } } @@ -95,13 +96,13 @@ function useProjectCollaboration() { onOk: async () => { try { await removeProjectMember(projectId, userId) - message.success('成员删除成功') + Toast.success('成员删除成功') const res = await getProjectMembers(projectId) setMembers(res.data || []) } catch (error) { console.error('Remove member error:', error) const errorMsg = error.response?.data?.detail || error.message || '删除成员失败' - message.error(errorMsg) + Toast.error(errorMsg) } }, }) diff --git a/frontend/src/pages/ProjectList/useProjectGitRepos.js b/frontend/src/pages/ProjectList/useProjectGitRepos.js index f7021b9..d0f190f 100644 --- a/frontend/src/pages/ProjectList/useProjectGitRepos.js +++ b/frontend/src/pages/ProjectList/useProjectGitRepos.js @@ -1,5 +1,6 @@ import { useState } from 'react' -import { message, Modal } from 'antd' +import { Modal } from 'antd' +import Toast from '@/components/Toast/Toast' import { createGitRepo, deleteGitRepo, getGitRepos, updateGitRepo } from '@/api/project' function useProjectGitRepos() { @@ -16,7 +17,7 @@ function useProjectGitRepos() { setGitRepos(res.data || []) } catch (error) { console.error('Fetch git repos error:', error) - message.error('加载Git仓库失败') + Toast.error('加载Git仓库失败') } finally { setLoadingRepos(false) } @@ -62,11 +63,11 @@ function useProjectGitRepos() { onOk: async () => { try { await deleteGitRepo(projectId, repoId) - message.success('删除成功') + Toast.success('删除成功') await fetchGitRepos(projectId) } catch (error) { console.error('Delete repo error:', error) - message.error('删除失败') + Toast.error('删除失败') } }, }) @@ -81,17 +82,17 @@ function useProjectGitRepos() { if (editingRepo) { await updateGitRepo(projectId, editingRepo.id, data) - message.success('更新成功') + Toast.success('更新成功') } else { await createGitRepo(projectId, data) - message.success('添加成功') + Toast.success('添加成功') } await fetchGitRepos(projectId) return true } catch (error) { console.error('Save repo error:', error) - message.error(editingRepo ? '更新失败' : '添加失败') + Toast.error(editingRepo ? '更新失败' : '添加失败') return false } } diff --git a/frontend/src/pages/ProjectList/useProjectKnowledge.js b/frontend/src/pages/ProjectList/useProjectKnowledge.js index 6ebd27b..15f01d4 100644 --- a/frontend/src/pages/ProjectList/useProjectKnowledge.js +++ b/frontend/src/pages/ProjectList/useProjectKnowledge.js @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' -import { message } from 'antd' import { getLatestVectorizeTask, getVectorizeProgress, getVectorizeTask, vectorizeProject } from '@/api/knowledgeBase' +import Toast from '@/components/Toast/Toast' function useProjectKnowledge() { const [kbModalVisible, setKbModalVisible] = useState(false) @@ -28,7 +28,7 @@ function useProjectKnowledge() { return res.data } catch (error) { console.error('Fetch vectorize progress error:', error) - if (!silent) message.error('加载向量化进度失败') + if (!silent) Toast.error('加载向量化进度失败') return null } finally { if (!silent) setLoadingProgress(false) @@ -64,13 +64,13 @@ function useProjectKnowledge() { setVectorizing(false) await fetchProgress(projectId, { silent: true }) if (task?.status === 'success') { - message.success( + Toast.success( `向量化完成:处理 ${task.processed || 0} 个` + `${task.skipped ? `,跳过 ${task.skipped} 个` : ''}` + `${task.failed ? `,失败 ${task.failed} 个` : ''}` ) } else if (task?.status === 'failed') { - message.error(task.error_message || '向量化任务失败') + Toast.error(task.error_message || '向量化任务失败') } } } catch (error) { @@ -109,7 +109,7 @@ function useProjectKnowledge() { const res = await vectorizeProject(projectId, force) const task = res.data || null setCurrentTask(task) - message.success('向量化任务已提交,正在后台执行') + Toast.success('向量化任务已提交,正在后台执行') await fetchProgress(projectId) if (task?.task_id) { pollTask(projectId, task.task_id) @@ -117,7 +117,7 @@ function useProjectKnowledge() { } catch (error) { console.error('Vectorize project error:', error) const detail = error.response?.data?.detail || '向量化失败' - message.error(detail) + Toast.error(detail) await fetchProgress(projectId, { silent: true }) } finally { if (!pollTimer.current) { diff --git a/frontend/src/pages/ProjectList/useProjectShare.js b/frontend/src/pages/ProjectList/useProjectShare.js index 6f48d08..7379274 100644 --- a/frontend/src/pages/ProjectList/useProjectShare.js +++ b/frontend/src/pages/ProjectList/useProjectShare.js @@ -1,5 +1,4 @@ import { useState } from 'react' -import { message } from 'antd' import { getProjectShareInfo, updateShareSettings } from '@/api/share' import Toast from '@/components/Toast/Toast' import { copyText } from '@/utils/browserIO' @@ -19,7 +18,7 @@ function useProjectShare({ type }) { setShareModalVisible(true) } catch (error) { console.error('Get share info error:', error) - message.error('获取分享信息失败') + Toast.error('获取分享信息失败') } } @@ -53,11 +52,11 @@ function useProjectShare({ type }) { await updateShareSettings(projectId, { access_pass: null }) setHasPassword(false) setPassword('') - message.success('已取消访问密码') + Toast.success('已取消访问密码') await refreshShareInfo(projectId) } catch (error) { console.error('Update settings error:', error) - message.error('操作失败') + Toast.error('操作失败') } return } @@ -67,18 +66,18 @@ function useProjectShare({ type }) { const savePassword = async (projectId) => { if (!password.trim()) { - message.warning('请输入访问密码') + Toast.warning('请输入访问密码') return } try { await updateShareSettings(projectId, { access_pass: password }) - message.success('访问密码已设置') + Toast.success('访问密码已设置') await refreshShareInfo(projectId) setHasPassword(true) } catch (error) { console.error('Save password error:', error) - message.error('设置密码失败') + Toast.error('设置密码失败') } } diff --git a/frontend/src/pages/SystemLogs/SystemLogs.jsx b/frontend/src/pages/SystemLogs/SystemLogs.jsx index 2e19f48..b110000 100644 --- a/frontend/src/pages/SystemLogs/SystemLogs.jsx +++ b/frontend/src/pages/SystemLogs/SystemLogs.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' -import { Table, Card, Select, DatePicker, Space, Button, Tag, Input, message } from 'antd' +import { Table, Card, Select, DatePicker, Space, Button, Tag, Input } from 'antd' +import Toast from '@/components/Toast/Toast' import { ReloadOutlined, SearchOutlined, FileSearchOutlined, CheckCircleOutlined, FolderOutlined, FileTextOutlined } from '@ant-design/icons' import { getOperationLogs, getLogStats } from '@/api/logs' import { getUserList } from '@/api/users' @@ -128,7 +129,7 @@ function SystemLogs() { }) } catch (error) { console.error('Fetch logs error:', error) - message.error('获取日志失败') + Toast.error('获取日志失败') } finally { setLoading(false) } @@ -146,12 +147,12 @@ function SystemLogs() { const fetchUsers = async () => { try { const res = await getUserList({ page: 1, page_size: 100, status: 1 }) - // 后端返回格式: { code: 200, message: "success", data: [...], total, page, page_size } + // 后端返回格式: { code: 200: "success", data: [...], total, page, page_size } const usersData = Array.isArray(res.data) ? res.data : [] setUsers(usersData) } catch (error) { console.error('Fetch users error:', error) - message.error('获取用户列表失败') + Toast.error('获取用户列表失败') } } diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js index 8353484..f5dd1b9 100644 --- a/frontend/src/utils/request.js +++ b/frontend/src/utils/request.js @@ -72,38 +72,46 @@ request.interceptors.response.use( console.error('Response error:', error) console.error('Response error detail:', error.response) + // 业务层自行提示的错误(skipErrorToast),不再重复弹 Toast,避免出现两个提示窗 + const skipErrorToast = error.config?.skipErrorToast === true + if (error.response) { const { status, data } = error.response - switch (status) { - case 401: - // 只有不在登录页时才显示Toast和跳转 - if (!window.location.pathname.includes('/login') && !isHandlingUnauthorized) { - isHandlingUnauthorized = true - Toast.muteErrors(2500) - Toast.error('认证失败', data?.detail || '未登录或登录已过期', 3, { force: true }) - clearAuthStorage() - setTimeout(() => { - redirectToLoginWithReturnTo() - }, 600) // 延迟一小段时间,让提示先展示 - } - break - case 403: - Toast.error('权限不足', data?.detail || '没有权限访问') - break - case 404: - Toast.error('资源不存在', data?.detail || '请求的资源不存在') - break - case 500: - Toast.error('服务器错误', data?.detail || '服务器内部错误') - break - default: - Toast.error('请求失败', data?.detail || data?.message || '请求失败') + // 401 始终走会话过期处理(清空凭证 + 跳转登录页) + if (status === 401) { + if (!window.location.pathname.includes('/login') && !isHandlingUnauthorized) { + isHandlingUnauthorized = true + Toast.muteErrors(2500) + Toast.error('认证失败', data?.detail || '未登录或登录已过期', 3, { force: true }) + clearAuthStorage() + setTimeout(() => { + redirectToLoginWithReturnTo() + }, 600) // 延迟一小段时间,让提示先展示 + } + } else if (!skipErrorToast) { + switch (status) { + case 403: + Toast.error('权限不足', data?.detail || '没有权限访问') + break + case 404: + Toast.error('资源不存在', data?.detail || '请求的资源不存在') + break + case 500: + Toast.error('服务器错误', data?.detail || '服务器内部错误') + break + default: + Toast.error('请求失败', data?.detail || data?.message || '请求失败') + } } } else if (error.request) { - Toast.error('网络错误', '请检查网络连接') + if (!skipErrorToast) { + Toast.error('网络错误', '请检查网络连接') + } } else { - Toast.error('请求错误', error.message || '请求配置错误') + if (!skipErrorToast) { + Toast.error('请求错误', error.message || '请求配置错误') + } } return Promise.reject(error)