v0.9.9-SP1

main
mula.liu 2026-08-25 10:17:40 +08:00
parent 22f2ce5543
commit eea3b96e0a
29 changed files with 2004 additions and 613 deletions

View File

@ -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;
}
});

View File

@ -0,0 +1,3 @@
export function apply(ctx) {
// Host half is intentionally empty: this plugin only contributes browser UI.
}

View File

@ -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"
}

View File

@ -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,

View File

@ -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):

View File

@ -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

View File

@ -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_COMMANDSSH 仓库走 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.connectTimeoutTCP/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,

View File

@ -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,
})
}

View File

@ -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('加载菜单失败')
}
}

View File

@ -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 = () => {

View File

@ -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文件加载失败')
}

View File

@ -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);
}

View File

@ -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
* - MarkdownFileTextOutlined标题去掉 .md 后缀
* - PDFFilePdfOutlined红色保留完整文件名
* - 其它文件FileOutlinedshowOtherFiles 时显示
* - 已分享角标ShareAltOutlinedshowShareIcon 时显示
* - 选中节点背景 + 文字 + 图标统一高亮
* - 悬停提示仅当名称显示不全省略号时才弹出 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 (
<Tooltip title={overflow ? title : ''} placement="right">
<span ref={textRef} className={className}>{children}</span>
</Tooltip>
)
}
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 <FilePdfOutlined style={{ color: '#f5222d' }} />
if (lower.endsWith('.md')) return <FileTextOutlined />
return <FileOutlined />
}
// + +
const buildLabel = (node) => {
const isPdf = node.title.toLowerCase().endsWith('.pdf')
const titleText = isPdf ? node.title : node.title.replace(/.md$/i, '')
const label = (
<span className="file-tree-label">
<OverflowTooltip title={node.title} className="file-tree-label-text">{titleText}</OverflowTooltip>
{showShareIcon && node.is_shared && (
<ShareAltOutlined className="file-tree-share-icon" title="已分享" />
)}
</span>
)
const contextItems = getContextMenuItems ? getContextMenuItems(node) : null
return contextItems && contextItems.length > 0
? <Dropdown menu={{ items: contextItems }} trigger={['contextMenu']}>{label}</Dropdown>
: 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 ? <FolderOpenOutlined /> : <FolderOutlined />,
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 (
<div className="file-tree-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyText} />
</div>
)
}
return (
<Menu
ref={menuRef}
mode="inline"
selectedKeys={selectedKey ? [selectedKey] : []}
openKeys={openKeys}
onOpenChange={onOpenChange}
items={menuItems}
onClick={onSelect}
className={['file-tree-menu', className].filter(Boolean).join(' ')}
/>
)
}

View File

@ -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 || '删除失败')
}
},
})

View File

@ -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)
}

View File

@ -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;

View File

@ -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: <FolderOutlined />,
onClick: () => selectRootFolder(),
}]
let currentPath = ''
folderParts.forEach((part) => {
currentPath = currentPath ? `${currentPath}/${part}` : part
const path = currentPath
items.push({
key: path,
label: part,
icon: <FolderOutlined />,
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 ? <FilePdfOutlined /> : <FileTextOutlined />,
})
}
return items
}
const renderContentBreadcrumb = () => {
const items = getBreadcrumbItems()
const { folders, files } = getFolderChildren(getBreadcrumbFolderPath())
return (
<div className="docs-breadcrumb-row">
<nav className="docs-breadcrumb" aria-label="文档路径">
{items.map((item, index) => {
const isLast = index === items.length - 1
const content = (
<>
{item.icon}
<span>{item.label}</span>
</>
)
return (
<span className="docs-breadcrumb-segment" key={item.key}>
{isLast ? (
<span className="docs-breadcrumb-current" aria-current="page">
{content}
</span>
) : (
<button type="button" className="docs-breadcrumb-link" onClick={item.onClick}>
{content}
</button>
)}
{!isLast && <span className="docs-breadcrumb-separator" aria-hidden="true">/</span>}
</span>
)
})}
</nav>
{selectedFolderKey && (
<div className="docs-folder-counts" aria-label="当前目录统计">
{folders.length} 个文件夹 · {files.length} 个文档
</div>
)}
</div>
)
}
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) => {
// 使Dropdownlabel
const isSelected = selectedMenuKey === node.key
const labelContent = (
<Dropdown
menu={{ items: getNodeMenuItems(node) }}
trigger={['contextMenu']}
>
<Tooltip title={node.title} placement="right">
<div className="tree-node-wrapper">
<span className="tree-node-text">
{node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title}
</span>
</div>
</Tooltip>
</Dropdown>
)
if (!node.isLeaf) {
const isOpen = openKeys.includes(node.key)
// - // CSS
return {
key: node.key,
label: labelContent,
icon: isOpen ? <FolderOpenOutlined /> : <FolderOutlined />,
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: <FileTextOutlined />,
}
} else if (node.title && node.title.toLowerCase().endsWith('.pdf')) {
// PDF
return {
key: node.key,
label: labelContent,
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
}
} else {
//
return {
key: node.key,
label: labelContent,
icon: <FileOutlined />,
}
}
}).filter(Boolean)
}
const menuItems = convertTreeToMenuItems(treeData)
return (
<div className="document-editor-page">
@ -1140,23 +1233,26 @@ function DocumentEditor() {
<Progress percent={uploadProgress} size="small" />
</div>
)}
<Menu
mode="inline"
selectedKeys={selectedMenuKey ? [selectedMenuKey] : []}
<ProjectFileTree
treeData={treeData}
selectedKey={selectedMenuKey}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={handleMenuClick}
className="file-tree"
onSelect={handleMenuClick}
onSelectFolder={(key) => selectFolder(key, { syncUrl: true })}
getContextMenuItems={getNodeMenuItems}
showOtherFiles
emptyText="暂无文档"
/>
</Sider>
<Content className="document-content">
<div className="content-header">
<h3 className="preview-header-title">
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
<span className="preview-header-text">{headerLabel}</span>
</h3>
{renderContentBreadcrumb()}
{isPdfSelected ? (
// PDF / VirtualPDFViewer
<div className="pdf-header-toolbar" ref={setPdfToolbarTarget} />
) : selectedFile ? (
<Space>
<Button
type="primary"
@ -1175,13 +1271,86 @@ function DocumentEditor() {
重置
</Button>
</Space>
) : null}
</div>
<div className="editor-container">
{isPdfSelected ? (
<div className="empty-editor">
<p>PDF文件请在浏览模式下查看</p>
<div className="pdf-editor-container">
<VirtualPDFViewer
url={pdfUrl}
filename={pdfFilename}
toolbarTarget={pdfToolbarTarget}
/>
</div>
) : selectedFolderKey ? (
(() => {
const { folders, files, node } = getFolderChildren(selectedFolderKey)
if (!node || node.isLeaf) {
return (
<div className="editor-folder-scroll">
<div className="docs-content-wrapper">
<div className="docs-folder-placeholder">
<FolderOpenOutlined />
<span>已选择文件夹请从左侧选择 Markdown PDF 文件编辑</span>
</div>
</div>
</div>
)
}
const total = folders.length + files.length
return (
<div className="editor-folder-scroll">
<div className="docs-content-wrapper">
<div className="docs-folder-view">
{total === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该文件夹为空"
style={{ padding: '48px 0' }}
/>
) : (
<div className="docs-folder-grid">
{folders.map(sub => (
<div
key={sub.key}
className="docs-folder-item"
onClick={() => selectFolder(sub.key, { syncUrl: true, expandSelf: true })}
title={sub.title}
>
<FolderOutlined className="docs-folder-item-icon" />
<span className="docs-folder-item-name">{sub.title}</span>
<span className="docs-folder-item-count">{sub.children?.length || 0} </span>
</div>
))}
{files.map(file => {
const isPdf = file.title.toLowerCase().endsWith('.pdf')
const displayName = isPdf ? file.title : file.title.replace(/.md$/i, '')
return (
<div
key={file.key}
className="docs-folder-item docs-file-item"
onClick={() => openNodeByKey(file.key, file, true)}
title={file.title}
>
{isPdf
? <FilePdfOutlined className="docs-folder-item-icon pdf" />
: <FileTextOutlined className="docs-folder-item-icon md" />}
<span className="docs-folder-item-name">
{displayName}
{file.is_shared && <ShareAltOutlined className="docs-folder-item-share" title="已分享" />}
</span>
<span className="docs-folder-item-count">{isPdf ? 'PDF 文档' : 'Markdown 文档'}</span>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
)
})()
) : selectedFile ? (
<div
className={`bytemd-wrapper ${isLargeMarkdown ? 'large-file-editor' : ''}`}

View File

@ -122,50 +122,6 @@
color: var(--link-color);
}
.docs-menu {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
border-right: none;
background: var(--sider-bg);
color: var(--text-color);
}
.docs-menu .ant-menu-item,
.docs-menu .ant-menu-submenu-title {
overflow: hidden;
}
.docs-menu .ant-menu-title-content {
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
}
.docs-menu-label {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
}
.docs-menu-label-text {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.docs-menu-share-icon {
color: var(--link-color);
font-size: 12px;
flex: none;
opacity: 0.9;
}
.docs-content-layout {
position: relative;
height: 100%;

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import { Layout, Menu, Spin, Button, Tooltip, message, Modal, Input, Space, Dropdown, Empty, Switch, Select, TreeSelect, Radio, Alert } from 'antd'
import { Layout, Spin, Button, Tooltip, Modal, Input, Space, Dropdown, Empty, Switch, Select, TreeSelect, Radio, Alert } from 'antd'
import { ShareAltOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, CloudDownloadOutlined, CloudUploadOutlined, ArrowLeftOutlined, ReloadOutlined, VerticalAlignTopOutlined } from '@ant-design/icons'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@ -21,6 +21,7 @@ import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
import FloatingToc from '@/components/FloatingToc/FloatingToc'
import Toast from '@/components/Toast/Toast'
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
import ProjectFileTree from '@/components/ProjectFileTree/ProjectFileTree'
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
import './DocumentPage.css'
@ -503,48 +504,6 @@ function DocumentPage() {
return null
}
//
const convertTreeToMenuItems = (nodes) => {
return nodes.map((node) => {
const titleText = node.title.endsWith('.md') ? node.title.replace('.md', '') : node.title
const labelNode = (
<Tooltip title={node.title} placement="right">
<span className="docs-menu-label">
<span className="docs-menu-label-text">{titleText}</span>
{node.is_shared && <ShareAltOutlined className="docs-menu-share-icon" title="已分享" />}
</span>
</Tooltip>
)
if (!node.isLeaf) {
const isOpen = openKeys.includes(node.key)
//
return {
key: node.key,
label: labelNode,
icon: isOpen ? <FolderOpenOutlined /> : <FolderOutlined />,
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: <FileTextOutlined />,
}
} else if (node.title && node.title.endsWith('.pdf')) {
// PDF
return {
key: node.key,
label: labelNode,
icon: <FilePdfOutlined style={{ color: '#f5222d' }} />,
}
}
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() {
>
<ArrowLeftOutlined />
</button>
<h2 title={projectName || '项目浏览'}>{projectName || '项目浏览'}</h2>
</div>
<div className="docs-sider-actions">
<div className="mode-actions-row">
@ -1351,21 +1327,16 @@ function DocumentPage() {
/>
</div>
{filteredTreeData.length > 0 ? (
<Menu
mode="inline"
selectedKeys={selectedNodeKey ? [selectedNodeKey] : []}
<ProjectFileTree
treeData={filteredTreeData}
selectedKey={selectedNodeKey}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={handleMenuClick}
className="docs-menu"
onSelect={handleMenuClick}
onSelectFolder={(key) => selectFolder(key, { syncUrl: true })}
showShareIcon
emptyText="未找到匹配文档"
/>
) : (
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-color-secondary)' }}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配文档" />
</div>
)}
</Sider>
{/* 右侧内容区 */}

View File

@ -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;
}

View File

@ -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)
const selectedParam = searchParams.get('selected')
// URL selected
if (selectedParam) {
if (selectedParam === ROOT_FOLDER_KEY) {
if (selectedNodeKey !== ROOT_FOLDER_KEY) {
selectRootFolder({ syncUrl: false })
}
return
}
if (!selectedFile) {
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)
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: <FolderOutlined />,
onClick: () => selectRootFolder(),
}]
let currentPath = ''
folderParts.forEach((part) => {
currentPath = currentPath ? `${currentPath}/${part}` : part
const path = currentPath
items.push({
key: path,
label: part,
icon: <FolderOutlined />,
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 ? <FilePdfOutlined /> : <FileTextOutlined />,
})
}
return items
}
const renderContentBreadcrumb = () => {
const items = getBreadcrumbItems()
const { folders, files } = getFolderChildren(getBreadcrumbFolderPath())
return (
<div className="preview-breadcrumb-row">
<nav className="preview-breadcrumb" aria-label="文档路径">
{items.map((item, index) => {
const isLast = index === items.length - 1
const content = (
<>
{item.icon}
<span>{item.label}</span>
</>
)
return (
<span className="preview-breadcrumb-segment" key={item.key}>
{isLast ? (
<span className="preview-breadcrumb-current" aria-current="page">
{content}
</span>
) : (
<button type="button" className="preview-breadcrumb-link" onClick={item.onClick}>
{content}
</button>
)}
{!isLast && <span className="preview-breadcrumb-separator" aria-hidden="true">/</span>}
</span>
)
})}
</nav>
{viewMode === 'folder' && (
<div className="preview-folder-counts" aria-label="当前目录统计">
{folders.length} 个文件夹 · {files.length} 个文档
</div>
)}
</div>
)
}
//
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 = (
<Tooltip title={node.title} placement="right">
<span className="preview-menu-label">{titleText}</span>
</Tooltip>
)
if (!node.isLeaf) {
const isOpen = openKeys.includes(node.key)
return {
key: node.key,
label: labelNode,
icon: isOpen ? <FolderOpenOutlined /> : <FolderOutlined />,
onTitleClick: () => setSelectedNodeKey(node.key),
children: node.children ? convertTreeToMenuItems(node.children) : [],
}
}
if (node.title?.endsWith('.md')) {
return { key: node.key, label: labelNode, icon: <FileTextOutlined /> }
}
if (node.title?.toLowerCase().endsWith('.pdf')) {
return { key: node.key, label: labelNode, icon: <FilePdfOutlined style={{ color: '#f5222d' }} /> }
}
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 (
<div className="preview-page">
<Layout className="preview-layout">
@ -448,22 +608,21 @@ function ProjectSharePage() {
enterButton
/>
</div>
{menuItems.length > 0 ? (
<Menu
mode="inline"
selectedKeys={selectedNodeKey ? [selectedNodeKey] : []}
<ProjectFileTree
treeData={filteredTreeData}
selectedKey={selectedNodeKey}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={({ key }) => {
openSharedFile(key)
onSelect={(info) => {
handleMenuClick(info)
setMobileDrawerVisible(false)
}}
className="preview-menu"
onSelectFolder={(key) => {
selectFolder(key, { syncUrl: true })
setMobileDrawerVisible(false)
}}
emptyText="暂无文档"
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无文档" />
)}
</Drawer>
</>
) : (
@ -492,21 +651,15 @@ function ProjectSharePage() {
enterButton
/>
</div>
{menuItems.length > 0 ? (
<Menu
mode="inline"
selectedKeys={selectedNodeKey ? [selectedNodeKey] : []}
<ProjectFileTree
treeData={filteredTreeData}
selectedKey={selectedNodeKey}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
onClick={({ key }) => openSharedFile(key)}
className="preview-menu"
onSelect={handleMenuClick}
onSelectFolder={(key) => selectFolder(key, { syncUrl: true })}
emptyText="暂无文档"
/>
) : (
<div style={{ padding: 20 }}>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无文档" />
</div>
)}
</Sider>
)}
@ -535,10 +688,7 @@ function ProjectSharePage() {
</Tooltip>
</div>
)}
<h3 className="preview-header-title">
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
<span className="preview-header-text">{headerLabel}</span>
</h3>
{renderContentBreadcrumb()}
{viewMode === 'markdown' && (
isMobile ? (
<Space className="preview-header-actions preview-compact-actions" size={4}>
@ -602,6 +752,63 @@ function ProjectSharePage() {
</div>
) : viewMode === 'pdf' ? (
<VirtualPDFViewer url={pdfUrl} filename={pdfFilename} toolbarTarget={pdfToolbarTarget} compactToolbar={isMobile} />
) : viewMode === 'folder' ? (
(() => {
const { folders, files, node } = getFolderChildren(selectedNodeKey)
if (!node || node.isLeaf) {
return (
<div className="preview-folder-placeholder">
<FolderOpenOutlined />
<span>已选择文件夹请从左侧选择 Markdown PDF 文件查看</span>
</div>
)
}
const total = folders.length + files.length
return (
<div className="preview-folder-view">
{total === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该文件夹为空"
style={{ padding: '48px 0' }}
/>
) : (
<div className="preview-folder-grid">
{folders.map(sub => (
<div
key={sub.key}
className="preview-folder-item"
onClick={() => selectFolder(sub.key, { syncUrl: true, expandSelf: true })}
title={sub.title}
>
<FolderOutlined className="preview-folder-item-icon" />
<span className="preview-folder-item-name">{sub.title}</span>
<span className="preview-folder-item-count">{sub.children?.length || 0} </span>
</div>
))}
{files.map(file => {
const isPdf = file.title.toLowerCase().endsWith('.pdf')
const displayName = isPdf ? file.title : file.title.replace(/.md$/i, '')
return (
<div
key={file.key}
className="preview-folder-item preview-file-item"
onClick={() => openSharedFile(file.key, { syncUrl: true })}
title={file.title}
>
{isPdf
? <FilePdfOutlined className="preview-folder-item-icon pdf" />
: <FileTextOutlined className="preview-folder-item-icon md" />}
<span className="preview-folder-item-name">{displayName}</span>
<span className="preview-folder-item-count">{isPdf ? 'PDF 文档' : 'Markdown 文档'}</span>
</div>
)
})}
</div>
)}
</div>
)
})()
) : isLargeMarkdown ? (
<LargeMarkdownViewer
ref={largeMarkdownRef}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Card, Tabs, Form, Input, Button, Avatar, Upload, message, Space, Typography, Modal } from 'antd'
import { Card, Tabs, Form, Input, Button, Avatar, Upload, Space, Typography, Modal } from 'antd'
import { UserOutlined, LockOutlined, UploadOutlined, ApiOutlined, CopyOutlined, ReloadOutlined } from '@ant-design/icons'
import ImgCrop from 'antd-img-crop'
import { getCurrentUser, updateProfile, changePassword, uploadAvatar, getMcpCredentials, rotateMcpSecret } from '@/api/auth'
@ -35,7 +35,7 @@ function ProfilePage() {
})
} catch (error) {
console.error('Load user info error:', error)
message.error('加载用户信息失败')
Toast.error('加载用户信息失败')
}
}
@ -63,7 +63,7 @@ function ProfilePage() {
Toast.success('更新成功', '个人资料已更新')
} catch (error) {
console.error('Update profile error:', error)
message.error(error.response?.data?.detail || '更新失败')
Toast.error(error.response?.data?.detail || '更新失败')
} finally {
setLoading(false)
}
@ -85,7 +85,7 @@ function ProfilePage() {
}, 2000)
} catch (error) {
console.error('Change password error:', error)
message.error(error.response?.data?.detail || '修改密码失败')
Toast.error(error.response?.data?.detail || '修改密码失败')
} finally {
setLoading(false)
}

View File

@ -1,10 +1,9 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, message, Select, Table, Tag, Pagination, Progress, Alert, List, Spin, Tooltip, TreeSelect } from 'antd'
import { Card, Empty, Modal, Form, Input, Row, Col, Space, Button, Switch, Select, Table, Tag, Pagination, Progress, Alert, List, Spin, Tooltip } from 'antd'
import { PlusOutlined, FolderOutlined, TeamOutlined, EyeOutlined, CopyOutlined, DeleteOutlined, EditOutlined, FileOutlined, BranchesOutlined, GithubOutlined, GithubFilled, CheckOutlined, SwapOutlined, SettingOutlined, DatabaseOutlined, ReloadOutlined, CalendarOutlined, FileTextOutlined, CrownOutlined } from '@ant-design/icons'
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, getGitRepoDirectories, transferProject } from '@/api/project'
import { getMyProjects, getOwnedProjects, getSharedProjects, createProject, deleteProject, updateProject, getProjectMembers, addProjectMember, removeProjectMember, getGitRepos, createGitRepo, updateGitRepo, deleteGitRepo, testGitRepoConnection, transferProject } from '@/api/project'
import { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
import { getProjectTree } from '@/api/file'
import { getUserList } from '@/api/users'
import { searchDocuments } from '@/api/search'
import { getUnreadByProject, markProjectNotificationsRead } from '@/api/notification'
@ -146,7 +145,7 @@ function ProjectList({ type = 'my' }) {
// API
setUsers(allUsers.filter(u => 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' }) {
<Input.Password placeholder="Git访问令牌" />
</Form.Item>
<Form.Item
label="同步目录"
name="sync_path"
extra="留空表示同步整个仓库;选择目录后,拉取/推送仅作用于该目录"
>
<TreeSelect
placeholder="整个仓库(留空)"
allowClear
showSearch
treeDefaultExpandAll
treeData={gitDirOptions}
fieldNames={{ label: 'title', value: 'value', children: 'children' }}
filterTreeNode={(inputValue, treeNode) =>
String(treeNode.title || '').toLowerCase().includes(inputValue.toLowerCase())
}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
name="is_default"
valuePropName="checked"
@ -1369,6 +1321,9 @@ function ProjectList({ type = 'my' }) {
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setGitRepoModalVisible(false)}>取消</Button>
<Button loading={testingGitRepo} onClick={handleTestRepoConnection}>
测试远端认证
</Button>
<Button type="primary" htmlType="submit">保存</Button>
</Space>
</Form.Item>

View File

@ -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)
}
},
})

View File

@ -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
}
}

View File

@ -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) {

View File

@ -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('设置密码失败')
}
}

View File

@ -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('获取用户列表失败')
}
}

View File

@ -72,12 +72,14 @@ 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和跳转
// 401 始终走会话过期处理(清空凭证 + 跳转登录页)
if (status === 401) {
if (!window.location.pathname.includes('/login') && !isHandlingUnauthorized) {
isHandlingUnauthorized = true
Toast.muteErrors(2500)
@ -87,7 +89,8 @@ request.interceptors.response.use(
redirectToLoginWithReturnTo()
}, 600) // 延迟一小段时间,让提示先展示
}
break
} else if (!skipErrorToast) {
switch (status) {
case 403:
Toast.error('权限不足', data?.detail || '没有权限访问')
break
@ -100,11 +103,16 @@ request.interceptors.response.use(
default:
Toast.error('请求失败', data?.detail || data?.message || '请求失败')
}
}
} else if (error.request) {
if (!skipErrorToast) {
Toast.error('网络错误', '请检查网络连接')
}
} else {
if (!skipErrorToast) {
Toast.error('请求错误', error.message || '请求配置错误')
}
}
return Promise.reject(error)
}