v0.9.9-SP1
parent
22f2ce5543
commit
eea3b96e0a
|
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function apply(ctx) {
|
||||||
|
// Host half is intentionally empty: this plugin only contributes browser UI.
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,8 @@ from app.core.database import get_db
|
||||||
from app.core.deps import get_current_user
|
from app.core.deps import get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.git_repo import ProjectGitRepo
|
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.schemas.response import success_response
|
||||||
from app.services.log_service import log_service
|
from app.services.log_service import log_service
|
||||||
from app.services.project_service import require_project_read_access, require_project_roles
|
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,
|
username=repo_in.username,
|
||||||
token=repo_in.token,
|
token=repo_in.token,
|
||||||
is_default=is_default,
|
is_default=is_default,
|
||||||
sync_path=repo_in.sync_path or ""
|
sync_path=""
|
||||||
)
|
)
|
||||||
db.add(db_repo)
|
db.add(db_repo)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
@ -95,6 +96,44 @@ async def create_git_repo(
|
||||||
return success_response(data=_repo_response(db_repo), message="Git仓库添加成功")
|
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)
|
@router.put("/projects/{project_id}/git-repos/{repo_id}", response_model=dict)
|
||||||
async def update_git_repo(
|
async def update_git_repo(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|
|
||||||
|
|
@ -816,6 +816,10 @@ async def git_pull(
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=500, detail=f"Git Pull失败: {msg}")
|
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(
|
await log_service.log_project_operation(
|
||||||
db=db,
|
db=db,
|
||||||
|
|
@ -839,7 +843,10 @@ async def git_pull(
|
||||||
)
|
)
|
||||||
await db.commit()
|
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)
|
@router.post("/{project_id}/git/push", response_model=dict)
|
||||||
|
|
@ -912,6 +919,9 @@ async def git_push(
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=500, detail=f"Git Push失败: {msg}")
|
raise HTTPException(status_code=500, detail=f"Git Push失败: {msg}")
|
||||||
|
|
||||||
|
# 同步目录只在实际 Push 成功后保存,空串表示整个仓库。
|
||||||
|
target_repo.sync_path = effective_sub_path
|
||||||
|
|
||||||
# 记录日志
|
# 记录日志
|
||||||
await log_service.log_project_operation(
|
await log_service.log_project_operation(
|
||||||
db=db,
|
db=db,
|
||||||
|
|
@ -922,7 +932,11 @@ async def git_push(
|
||||||
request=request,
|
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):
|
def _build_remote_dir_tree(paths):
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,16 @@ from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
class GitRepoBase(BaseModel):
|
class GitRepoConfigBase(BaseModel):
|
||||||
name: str = Field(..., max_length=50, description="仓库别名")
|
name: str = Field(..., max_length=50, description="仓库别名")
|
||||||
repo_url: str = Field(..., max_length=255, description="Git仓库地址")
|
repo_url: str = Field(..., max_length=255, description="Git仓库地址")
|
||||||
branch: str = Field("main", max_length=50, description="Git分支")
|
branch: str = Field("main", max_length=50, description="Git分支")
|
||||||
username: Optional[str] = Field(None, description="Git用户名")
|
username: Optional[str] = Field(None, description="Git用户名")
|
||||||
token: Optional[str] = Field(None, description="Git访问令牌")
|
token: Optional[str] = Field(None, description="Git访问令牌")
|
||||||
is_default: int = Field(0, description="是否默认仓库")
|
is_default: int = Field(0, description="是否默认仓库")
|
||||||
sync_path: str = Field("", max_length=255, description="同步目录(空=整个仓库)")
|
|
||||||
|
|
||||||
|
|
||||||
class GitRepoCreate(GitRepoBase):
|
class GitRepoCreate(GitRepoConfigBase):
|
||||||
"""创建Git仓库 Schema"""
|
"""创建Git仓库 Schema"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -29,11 +28,20 @@ class GitRepoUpdate(BaseModel):
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
token: Optional[str] = None
|
token: Optional[str] = None
|
||||||
is_default: Optional[int] = 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"""
|
"""Git仓库响应 Schema"""
|
||||||
|
sync_path: str = Field("", max_length=255, description="最近一次成功同步的目录")
|
||||||
id: int
|
id: int
|
||||||
project_id: int
|
project_id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,18 @@ class GitService:
|
||||||
return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, parsed.fragment))
|
return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, parsed.fragment))
|
||||||
|
|
||||||
def _auth_env(self, username: str = None, token: str = None) -> dict:
|
def _auth_env(self, username: str = None, token: str = None) -> dict:
|
||||||
"""Pass HTTPS Basic credentials to Git for this process only."""
|
"""Pass HTTPS Basic credentials to Git for this process only.
|
||||||
env = {"GIT_TERMINAL_PROMPT": "0"}
|
|
||||||
|
同时禁止交互式提示,避免凭证/主机密钥校验卡住进程:
|
||||||
|
- GIT_TERMINAL_PROMPT=0:禁止 Git 弹出用户名/密码输入
|
||||||
|
- GIT_ASKPASS=echo:凭证缺失时立即返回空串而不是挂起
|
||||||
|
- GIT_SSH_COMMAND:SSH 仓库走 BatchMode + 连接超时,不等待交互
|
||||||
|
"""
|
||||||
|
env = {
|
||||||
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
|
"GIT_ASKPASS": "echo",
|
||||||
|
"GIT_SSH_COMMAND": "ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new",
|
||||||
|
}
|
||||||
if username and token:
|
if username and token:
|
||||||
credential = base64.b64encode(f"{username}:{token}".encode()).decode()
|
credential = base64.b64encode(f"{username}:{token}".encode()).decode()
|
||||||
env.update({
|
env.update({
|
||||||
|
|
@ -263,6 +273,75 @@ class GitService:
|
||||||
"""Run push outside the async event loop."""
|
"""Run push outside the async event loop."""
|
||||||
return await asyncio.to_thread(self._push, *args, **kwargs)
|
return await asyncio.to_thread(self._push, *args, **kwargs)
|
||||||
|
|
||||||
|
def _test_connection(
|
||||||
|
self, repo_url: str, branch: str = "main", username: str = None, token: str = None
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""Verify remote access without changing the project working tree.
|
||||||
|
|
||||||
|
通过 Git 配置让连接快速失败,避免错误配置(地址错误、认证失败、
|
||||||
|
网络不可达)长时间挂起后仅返回"命令超时":
|
||||||
|
- http.connectTimeout:TCP/TLS 连接阶段超时(含 DNS)
|
||||||
|
- http.lowSpeedLimit/lowSpeedTime:连接后传输过慢视为失败
|
||||||
|
"""
|
||||||
|
if not repo_url or not str(repo_url).strip():
|
||||||
|
return False, "仓库地址不能为空"
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nex-docus-git-test-") as temp_path:
|
||||||
|
cwd = Path(temp_path)
|
||||||
|
ok, message = self._run_command(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"-c", "http.connectTimeout=10",
|
||||||
|
"-c", "http.lowSpeedLimit=1000",
|
||||||
|
"-c", "http.lowSpeedTime=15",
|
||||||
|
"ls-remote", "--heads", self._clean_repo_url(repo_url),
|
||||||
|
f"refs/heads/{branch or 'main'}",
|
||||||
|
],
|
||||||
|
cwd,
|
||||||
|
self._auth_env(username, token),
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
return True, ""
|
||||||
|
return False, self._humanize_git_error(message)
|
||||||
|
|
||||||
|
def _humanize_git_error(self, message: str) -> str:
|
||||||
|
"""把底层 Git/libcurl 报错翻译成对用户友好的提示。"""
|
||||||
|
if not message:
|
||||||
|
return "无法连接远端仓库"
|
||||||
|
lowered = message.lower()
|
||||||
|
if "超时" in message or "timed out" in lowered or "operation timed out" in lowered:
|
||||||
|
return "连接远端仓库超时,请检查仓库地址、网络或代理设置"
|
||||||
|
if "could not resolve host" in lowered or "name or service not known" in lowered:
|
||||||
|
return "无法解析仓库地址,请检查仓库 URL 是否正确"
|
||||||
|
if (
|
||||||
|
"connection refused" in lowered
|
||||||
|
or "connection reset" in lowered
|
||||||
|
or "couldn't connect" in lowered
|
||||||
|
or "could not connect" in lowered
|
||||||
|
or "failed to connect" in lowered
|
||||||
|
or "unable to access" in lowered
|
||||||
|
or "connection timed out" in lowered
|
||||||
|
):
|
||||||
|
return "无法连接到远端仓库,请检查仓库地址、网络或代理设置"
|
||||||
|
if "authentication failed" in lowered or "401" in lowered or "403" in lowered:
|
||||||
|
return "认证失败,请检查用户名与访问令牌是否正确"
|
||||||
|
if "could not read username" in lowered or "terminal prompts disabled" in lowered:
|
||||||
|
return "未提供有效的用户名与访问令牌"
|
||||||
|
if "host key verification failed" in lowered:
|
||||||
|
return "SSH 主机密钥校验失败,请确认该主机已受信任"
|
||||||
|
if "permission denied" in lowered or "publickey" in lowered:
|
||||||
|
return "SSH 认证失败,请检查用户名与访问令牌"
|
||||||
|
if "repository not found" in lowered or "not found" in lowered or "does not exist" in lowered:
|
||||||
|
return "远端仓库不存在或无权访问"
|
||||||
|
return message
|
||||||
|
|
||||||
|
async def test_connection(
|
||||||
|
self, repo_url: str, branch: str = "main", username: str = None, token: str = None
|
||||||
|
) -> Tuple[bool, str]:
|
||||||
|
"""Run remote authentication test outside the async event loop."""
|
||||||
|
return await asyncio.to_thread(self._test_connection, repo_url, branch, username, token)
|
||||||
|
|
||||||
def list_remote_tree(
|
def list_remote_tree(
|
||||||
self, project_path: Path, repo_url: str, branch: str = "main", username: str = None,
|
self, project_path: Path, repo_url: str, branch: str = "main", username: str = None,
|
||||||
token: str = None,
|
token: str = None,
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,8 @@ export function gitPull(projectId, repoId = null, force = false, syncScope = nul
|
||||||
return request({
|
return request({
|
||||||
url: `/projects/${projectId}/git/pull`,
|
url: `/projects/${projectId}/git/pull`,
|
||||||
method: 'post',
|
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({
|
return request({
|
||||||
url: `/projects/${projectId}/git/push`,
|
url: `/projects/${projectId}/git/push`,
|
||||||
method: 'post',
|
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`,
|
url: `/projects/${projectId}/git-repos`,
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data,
|
data,
|
||||||
|
skipErrorToast: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,6 +203,7 @@ export function updateGitRepo(projectId, repoId, data) {
|
||||||
url: `/projects/${projectId}/git-repos/${repoId}`,
|
url: `/projects/${projectId}/git-repos/${repoId}`,
|
||||||
method: 'put',
|
method: 'put',
|
||||||
data,
|
data,
|
||||||
|
skipErrorToast: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,6 +214,20 @@ export function deleteGitRepo(projectId, repoId) {
|
||||||
return request({
|
return request({
|
||||||
url: `/projects/${projectId}/git-repos/${repoId}`,
|
url: `/projects/${projectId}/git-repos/${repoId}`,
|
||||||
method: 'delete',
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ import {
|
||||||
BookOutlined,
|
BookOutlined,
|
||||||
CommentOutlined,
|
CommentOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { message } from 'antd'
|
|
||||||
import { getUserMenus } from '@/api/menu'
|
import { getUserMenus } from '@/api/menu'
|
||||||
|
import Toast from '@/components/Toast/Toast'
|
||||||
import useUserStore from '@/stores/userStore'
|
import useUserStore from '@/stores/userStore'
|
||||||
import ModernSidebar from '../ModernSidebar/ModernSidebar'
|
import ModernSidebar from '../ModernSidebar/ModernSidebar'
|
||||||
|
|
||||||
|
|
@ -194,7 +194,7 @@ function AppSider({ collapsed, onToggle }) {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Load menus error:', error)
|
console.error('Load menus error:', error)
|
||||||
message.error('加载菜单失败')
|
Toast.error('加载菜单失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { Document, Page, pdfjs } from 'react-pdf'
|
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 {
|
import {
|
||||||
LeftOutlined,
|
LeftOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
|
|
@ -28,7 +29,7 @@ function PDFViewer({ url, filename }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDocumentLoadError = (error) => {
|
const onDocumentLoadError = (error) => {
|
||||||
message.error('PDF文件加载失败')
|
Toast.error('PDF文件加载失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
const goToPrevPage = () => {
|
const goToPrevPage = () => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Document, Page, pdfjs } from 'react-pdf'
|
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 {
|
import {
|
||||||
ZoomInOutlined,
|
ZoomInOutlined,
|
||||||
ZoomOutOutlined,
|
ZoomOutOutlined,
|
||||||
|
|
@ -44,7 +45,7 @@ function VirtualPDFViewer({ url, filename, toolbarTarget, compactToolbar = false
|
||||||
|
|
||||||
const onDocumentLoadError = (error) => {
|
const onDocumentLoadError = (error) => {
|
||||||
console.error('[PDF] Document load error:', error)
|
console.error('[PDF] Document load error:', error)
|
||||||
message.error('PDF文件加载失败')
|
Toast.error('PDF文件加载失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
import { useState, useMemo, useRef, useEffect } from 'react'
|
||||||
|
import { Menu, Tooltip, Dropdown, Empty } from 'antd'
|
||||||
|
import {
|
||||||
|
FolderOutlined,
|
||||||
|
FolderOpenOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
FilePdfOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import './ProjectFileTree.css'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的项目文件树组件(浏览模式 / 编辑模式 / 分享模式共用)。
|
||||||
|
*
|
||||||
|
* 统一规则:
|
||||||
|
* - 文件夹:展开/收起图标,点击标题进入文件夹(onSelectFolder)
|
||||||
|
* - Markdown:FileTextOutlined,标题去掉 .md 后缀
|
||||||
|
* - PDF:FilePdfOutlined(红色),保留完整文件名
|
||||||
|
* - 其它文件:FileOutlined(showOtherFiles 时显示)
|
||||||
|
* - 已分享角标:ShareAltOutlined(showShareIcon 时显示)
|
||||||
|
* - 选中节点:背景 + 文字 + 图标统一高亮
|
||||||
|
* - 悬停提示:仅当名称显示不全(省略号)时才弹出 Tooltip
|
||||||
|
* - 切换文件夹:收拢其它展开目录,聚焦到新文件夹路径并滚动到可视区
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* - treeData: 树节点数组(可传过滤后的数据)
|
||||||
|
* - selectedKey: 当前选中的节点 key(高亮 + 自动聚焦)
|
||||||
|
* - openKeys / onOpenChange: 目录展开状态
|
||||||
|
* - onSelect: 菜单项点击(文件夹选中 / 文件打开)
|
||||||
|
* - onSelectFolder: 目录标题点击(onTitleClick)
|
||||||
|
* - getContextMenuItems: (node) => antd menu items | null,传入则启用右键菜单
|
||||||
|
* - collapseOnSelect: 切换文件夹时收拢其它目录(默认 true)
|
||||||
|
* - showShareIcon: 是否显示"已分享"角标(默认 false)
|
||||||
|
* - showOtherFiles: 是否显示非 md/pdf 文件(默认 false)
|
||||||
|
* - emptyText: 空状态文案
|
||||||
|
* - className: 追加到菜单根节点的 class
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 按 key 查找树节点
|
||||||
|
const findNodeByKey = (nodes, key) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.key === key) return node
|
||||||
|
if (node.children && node.children.length) {
|
||||||
|
const found = findNodeByKey(node.children, key)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算路径的所有父级目录(不含自身),如 "a/b/c" -> ["a", "a/b"]
|
||||||
|
const getAncestorPaths = (path) => {
|
||||||
|
const parts = path.split('/')
|
||||||
|
const ancestors = []
|
||||||
|
let current = ''
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
current = current ? current + '/' + parts[i] : parts[i]
|
||||||
|
ancestors.push(current)
|
||||||
|
}
|
||||||
|
return ancestors
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅当文本被省略(显示不全)时才显示 Tooltip,避免冗余提示
|
||||||
|
function OverflowTooltip({ title, className, children }) {
|
||||||
|
const textRef = useRef(null)
|
||||||
|
const [overflow, setOverflow] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = textRef.current
|
||||||
|
if (!el) return
|
||||||
|
const check = () => setOverflow(el.scrollWidth > el.clientWidth + 1)
|
||||||
|
check()
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
const ro = new ResizeObserver(check)
|
||||||
|
ro.observe(el)
|
||||||
|
return () => ro.disconnect()
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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(' ')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -15,8 +15,8 @@ import {
|
||||||
Spin,
|
Spin,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
|
import Toast from '@/components/Toast/Toast'
|
||||||
import {
|
import {
|
||||||
FolderOutlined,
|
FolderOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
|
@ -392,7 +392,7 @@ function MessageActions({ content, onCopy, onDelete, onRegenerate }) {
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
window.setTimeout(() => setCopied(false), 1500)
|
window.setTimeout(() => setCopied(false), 1500)
|
||||||
} else {
|
} else {
|
||||||
message.error('复制失败')
|
Toast.error('复制失败')
|
||||||
}
|
}
|
||||||
onCopy?.(ok)
|
onCopy?.(ok)
|
||||||
}
|
}
|
||||||
|
|
@ -538,7 +538,7 @@ function Chat() {
|
||||||
const res = await getChatSessions()
|
const res = await getChatSessions()
|
||||||
setSessions(res.data || [])
|
setSessions(res.data || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('加载对话列表失败')
|
Toast.error('加载对话列表失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSessions(false)
|
setLoadingSessions(false)
|
||||||
}
|
}
|
||||||
|
|
@ -607,7 +607,7 @@ function Chat() {
|
||||||
setMessages(nextMessages)
|
setMessages(nextMessages)
|
||||||
setCurrentSession(sessions.find((item) => item.session_id === id) || null)
|
setCurrentSession(sessions.find((item) => item.session_id === id) || null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('加载对话失败')
|
Toast.error('加载对话失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMessages(false)
|
setLoadingMessages(false)
|
||||||
}
|
}
|
||||||
|
|
@ -685,15 +685,15 @@ function Chat() {
|
||||||
if (sending) return
|
if (sending) return
|
||||||
const question = newQuestion.trim()
|
const question = newQuestion.trim()
|
||||||
if (!question) {
|
if (!question) {
|
||||||
message.warning('请输入问题')
|
Toast.warning('请输入问题')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!newProjectId) {
|
if (!newProjectId) {
|
||||||
message.warning('请选择项目')
|
Toast.warning('请选择项目')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!newModelId) {
|
if (!newModelId) {
|
||||||
message.warning('请选择模型')
|
Toast.warning('请选择模型')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const title = question.length > 24 ? `${question.slice(0, 24)}...` : question
|
const title = question.length > 24 ? `${question.slice(0, 24)}...` : question
|
||||||
|
|
@ -703,7 +703,7 @@ function Chat() {
|
||||||
const res = await createChatSession(newProjectId, newModelId, title)
|
const res = await createChatSession(newProjectId, newModelId, title)
|
||||||
session = res.data
|
session = res.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.response?.data?.detail || error.message || '新建对话失败')
|
Toast.error(error.response?.data?.detail || error.message || '新建对话失败')
|
||||||
setSending(false)
|
setSending(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -741,7 +741,7 @@ function Chat() {
|
||||||
})
|
})
|
||||||
touchSession(nextSession.session_id)
|
touchSession(nextSession.session_id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.response?.data?.detail || error.message || '对话已创建,但首条消息发送失败')
|
Toast.error(error.response?.data?.detail || error.message || '对话已创建,但首条消息发送失败')
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false)
|
setSending(false)
|
||||||
}
|
}
|
||||||
|
|
@ -758,7 +758,7 @@ function Chat() {
|
||||||
await sendMessageWithStream(currentSession, content)
|
await sendMessageWithStream(currentSession, content)
|
||||||
touchSession(currentSession.session_id)
|
touchSession(currentSession.session_id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.response?.data?.detail || error.message || '发送消息失败')
|
Toast.error(error.response?.data?.detail || error.message || '发送消息失败')
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false)
|
setSending(false)
|
||||||
}
|
}
|
||||||
|
|
@ -780,7 +780,7 @@ function Chat() {
|
||||||
try {
|
try {
|
||||||
await deleteChatMessage(item.id)
|
await deleteChatMessage(item.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.response?.data?.detail || '删除旧回复失败')
|
Toast.error(error.response?.data?.detail || '删除旧回复失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -797,7 +797,7 @@ function Chat() {
|
||||||
})
|
})
|
||||||
touchSession(currentSession.session_id)
|
touchSession(currentSession.session_id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.response?.data?.detail || error.message || '重新生成失败')
|
Toast.error(error.response?.data?.detail || error.message || '重新生成失败')
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false)
|
setSending(false)
|
||||||
}
|
}
|
||||||
|
|
@ -979,7 +979,7 @@ function Chat() {
|
||||||
await deleteChatMessage(item.id)
|
await deleteChatMessage(item.id)
|
||||||
setMessages((prev) => prev.filter((m) => m.id !== item.id))
|
setMessages((prev) => prev.filter((m) => m.id !== item.id))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.response?.data?.detail || '删除失败')
|
Toast.error(error.response?.data?.detail || '删除失败')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -1011,7 +1011,7 @@ function Chat() {
|
||||||
setHasSearched(true)
|
setHasSearched(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestId === searchRequestIdRef.current) {
|
if (requestId === searchRequestIdRef.current) {
|
||||||
message.error(error.response?.data?.detail || error.message || '搜索失败')
|
Toast.error(error.response?.data?.detail || error.message || '搜索失败')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === searchRequestIdRef.current) {
|
if (requestId === searchRequestIdRef.current) {
|
||||||
|
|
@ -1040,7 +1040,7 @@ function Chat() {
|
||||||
const handleRenameSession = async () => {
|
const handleRenameSession = async () => {
|
||||||
const title = renameTitle.trim()
|
const title = renameTitle.trim()
|
||||||
if (!renameSession || !title) {
|
if (!renameSession || !title) {
|
||||||
message.warning('请输入对话名称')
|
Toast.warning('请输入对话名称')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -1055,7 +1055,7 @@ function Chat() {
|
||||||
setRenameSession(null)
|
setRenameSession(null)
|
||||||
setRenameTitle('')
|
setRenameTitle('')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.response?.data?.detail || '重命名失败')
|
Toast.error(error.response?.data?.detail || '重命名失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1076,7 +1076,7 @@ function Chat() {
|
||||||
navigate('/chat', { replace: true })
|
navigate('/chat', { replace: true })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.response?.data?.detail || '删除失败')
|
Toast.error(error.response?.data?.detail || '删除失败')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from 'react'
|
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 { UserOutlined, ProjectOutlined, FileTextOutlined, SyncOutlined, DashboardOutlined } from '@ant-design/icons'
|
||||||
import { getDashboardStats } from '@/api/dashboard'
|
import { getDashboardStats } from '@/api/dashboard'
|
||||||
import { rebuildIndex } from '@/api/search'
|
import { rebuildIndex } from '@/api/search'
|
||||||
|
|
@ -43,10 +43,10 @@ function Dashboard() {
|
||||||
setRebuilding(true)
|
setRebuilding(true)
|
||||||
try {
|
try {
|
||||||
await rebuildIndex()
|
await rebuildIndex()
|
||||||
message.success('索引重建任务已启动,请稍后搜索尝试')
|
Toast.success('索引重建任务已启动,请稍后搜索尝试')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Rebuild index error:', error)
|
console.error('Rebuild index error:', error)
|
||||||
message.error('重建索引失败: ' + (error.response?.data?.detail || error.message))
|
Toast.error('重建索引失败: ' + (error.response?.data?.detail || error.message))
|
||||||
} finally {
|
} finally {
|
||||||
setRebuilding(false)
|
setRebuilding(false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,85 +127,6 @@
|
||||||
color: var(--link-color);
|
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 {
|
.document-content {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -232,6 +153,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
background: var(--header-bg);
|
background: var(--header-bg);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -283,6 +205,38 @@
|
||||||
color: var(--text-color-secondary);
|
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 编辑器样式覆盖 */
|
||||||
.bytemd {
|
.bytemd {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
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 {
|
import {
|
||||||
FileOutlined,
|
|
||||||
FolderOutlined,
|
FolderOutlined,
|
||||||
FolderOpenOutlined,
|
FolderOpenOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
|
|
@ -17,6 +16,7 @@ import {
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
UndoOutlined,
|
UndoOutlined,
|
||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { Editor } from '@bytemd/react'
|
import { Editor } from '@bytemd/react'
|
||||||
import gfm from '@bytemd/plugin-gfm'
|
import gfm from '@bytemd/plugin-gfm'
|
||||||
|
|
@ -40,9 +40,12 @@ import Toast from '@/components/Toast/Toast'
|
||||||
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
|
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
|
||||||
import { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
|
import { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
|
||||||
import LargeMarkdownEditor from '@/components/LargeMarkdownViewer/LargeMarkdownEditor'
|
import LargeMarkdownEditor from '@/components/LargeMarkdownViewer/LargeMarkdownEditor'
|
||||||
|
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||||||
|
import ProjectFileTree from '@/components/ProjectFileTree/ProjectFileTree'
|
||||||
import './DocumentEditor.css'
|
import './DocumentEditor.css'
|
||||||
|
|
||||||
const { Sider, Content } = Layout
|
const { Sider, Content } = Layout
|
||||||
|
const ROOT_FOLDER_KEY = '__root__'
|
||||||
|
|
||||||
function DocumentEditor() {
|
function DocumentEditor() {
|
||||||
const { projectId } = useParams()
|
const { projectId } = useParams()
|
||||||
|
|
@ -70,6 +73,10 @@ function DocumentEditor() {
|
||||||
const [selectedMenuKey, setSelectedMenuKey] = useState(null) // 当前选中的菜单项(文件或文件夹)
|
const [selectedMenuKey, setSelectedMenuKey] = useState(null) // 当前选中的菜单项(文件或文件夹)
|
||||||
const uploadingRef = useRef(false) // 使用ref防止重复上传
|
const uploadingRef = useRef(false) // 使用ref防止重复上传
|
||||||
const [isPdfSelected, setIsPdfSelected] = useState(false) // 是否选中了PDF文件
|
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 [linkModalVisible, setLinkModalVisible] = useState(false)
|
||||||
const [linkTarget, setLinkTarget] = useState(null)
|
const [linkTarget, setLinkTarget] = useState(null)
|
||||||
const [projectName, setProjectName] = useState('') // 项目名称
|
const [projectName, setProjectName] = useState('') // 项目名称
|
||||||
|
|
@ -79,10 +86,6 @@ function DocumentEditor() {
|
||||||
const modeSwitchingRef = useRef(false)
|
const modeSwitchingRef = useRef(false)
|
||||||
const isLargeMarkdown = isLargeMarkdownContent(fileContent)
|
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 linkTreeData = useMemo(() => {
|
||||||
const markSelectable = (nodes) => nodes.map((node) => {
|
const markSelectable = (nodes) => nodes.map((node) => {
|
||||||
if (node.children?.length) {
|
if (node.children?.length) {
|
||||||
|
|
@ -206,7 +209,7 @@ function DocumentEditor() {
|
||||||
downloadByUrl(buildDocumentUrl(path), getDownloadFilename(path))
|
downloadByUrl(buildDocumentUrl(path), getDownloadFilename(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectFolder = (folderPath, { syncUrl = false } = {}) => {
|
const selectFolder = (folderPath, { syncUrl = false, expandSelf = false } = {}) => {
|
||||||
const targetNode = findNodeByKey(treeData, folderPath)
|
const targetNode = findNodeByKey(treeData, folderPath)
|
||||||
if (targetNode) {
|
if (targetNode) {
|
||||||
setSelectedNode(targetNode)
|
setSelectedNode(targetNode)
|
||||||
|
|
@ -215,17 +218,149 @@ function DocumentEditor() {
|
||||||
setSelectedFile(null)
|
setSelectedFile(null)
|
||||||
setIsPdfSelected(false)
|
setIsPdfSelected(false)
|
||||||
setFileContent('')
|
setFileContent('')
|
||||||
|
setPdfUrl('')
|
||||||
|
setPdfFilename('')
|
||||||
|
setSelectedFolderKey(folderPath)
|
||||||
|
|
||||||
|
// 自动展开父级目录,保证选中文件夹在左侧文件树中可见(与浏览模式一致)
|
||||||
|
expandParentFolders(`${folderPath}/placeholder`)
|
||||||
|
|
||||||
|
// 从内容区点击进入子文件夹时,同时展开自身,保证与左侧文件树同步
|
||||||
|
if (expandSelf) {
|
||||||
|
setOpenKeys(prev => (prev.includes(folderPath) ? prev : [...prev, folderPath]))
|
||||||
|
}
|
||||||
|
|
||||||
if (syncUrl) {
|
if (syncUrl) {
|
||||||
updateSelectedParam(folderPath, false)
|
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 getModeSwitchTarget = () => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
|
|
||||||
if (selectedFile) {
|
if (selectedFile) {
|
||||||
params.set('file', selectedFile)
|
params.set('file', selectedFile)
|
||||||
|
} else if (selectedFolderKey === ROOT_FOLDER_KEY) {
|
||||||
|
params.set('selected', ROOT_FOLDER_KEY)
|
||||||
} else if (selectedMenuKey) {
|
} else if (selectedMenuKey) {
|
||||||
const targetNode = findNodeByKey(treeData, selectedMenuKey)
|
const targetNode = findNodeByKey(treeData, selectedMenuKey)
|
||||||
if (targetNode && !targetNode.isLeaf) {
|
if (targetNode && !targetNode.isLeaf) {
|
||||||
|
|
@ -355,6 +490,9 @@ function DocumentEditor() {
|
||||||
setSelectedFile(null)
|
setSelectedFile(null)
|
||||||
setIsPdfSelected(false)
|
setIsPdfSelected(false)
|
||||||
setFileContent('')
|
setFileContent('')
|
||||||
|
setPdfUrl('')
|
||||||
|
setPdfFilename('')
|
||||||
|
setSelectedFolderKey(key)
|
||||||
if (syncUrl) {
|
if (syncUrl) {
|
||||||
updateSelectedParam(key, false)
|
updateSelectedParam(key, false)
|
||||||
}
|
}
|
||||||
|
|
@ -366,14 +504,21 @@ function DocumentEditor() {
|
||||||
}
|
}
|
||||||
expandParentFolders(key)
|
expandParentFolders(key)
|
||||||
|
|
||||||
|
// PDF 文件:编辑模式也内置只读预览(与浏览模式一致),不弹"请切换浏览模式"
|
||||||
if (key.toLowerCase().endsWith('.pdf')) {
|
if (key.toLowerCase().endsWith('.pdf')) {
|
||||||
setSelectedFile(key)
|
setSelectedFile(key)
|
||||||
setIsPdfSelected(true)
|
setIsPdfSelected(true)
|
||||||
setFileContent('')
|
setFileContent('')
|
||||||
|
setPdfUrl(buildDocumentUrl(key))
|
||||||
|
setPdfFilename(key.split('/').pop())
|
||||||
|
setSelectedFolderKey(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsPdfSelected(false)
|
setIsPdfSelected(false)
|
||||||
|
setPdfUrl('')
|
||||||
|
setPdfFilename('')
|
||||||
|
setSelectedFolderKey(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await getFileContent(projectId, key)
|
const res = await getFileContent(projectId, key)
|
||||||
|
|
@ -393,6 +538,12 @@ function DocumentEditor() {
|
||||||
const selectedParam = searchParams.get('selected')
|
const selectedParam = searchParams.get('selected')
|
||||||
|
|
||||||
if (selectedParam) {
|
if (selectedParam) {
|
||||||
|
if (selectedParam === ROOT_FOLDER_KEY) {
|
||||||
|
if (selectedFolderKey !== ROOT_FOLDER_KEY) {
|
||||||
|
selectRootFolder({ syncUrl: false })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
const targetNode = findNodeByKey(treeData, selectedParam)
|
const targetNode = findNodeByKey(treeData, selectedParam)
|
||||||
if (targetNode && !targetNode.isLeaf && selectedParam !== selectedMenuKey) {
|
if (targetNode && !targetNode.isLeaf && selectedParam !== selectedMenuKey) {
|
||||||
selectFolder(selectedParam)
|
selectFolder(selectedParam)
|
||||||
|
|
@ -997,65 +1148,7 @@ function DocumentEditor() {
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
// 转换文件树为菜单项
|
|
||||||
const convertTreeToMenuItems = (nodes) => {
|
|
||||||
return nodes.map((node) => {
|
|
||||||
// 使用Dropdown包裹label,实现右键菜单
|
|
||||||
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 (
|
return (
|
||||||
<div className="document-editor-page">
|
<div className="document-editor-page">
|
||||||
|
|
@ -1140,48 +1233,124 @@ function DocumentEditor() {
|
||||||
<Progress percent={uploadProgress} size="small" />
|
<Progress percent={uploadProgress} size="small" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Menu
|
<ProjectFileTree
|
||||||
mode="inline"
|
treeData={treeData}
|
||||||
selectedKeys={selectedMenuKey ? [selectedMenuKey] : []}
|
selectedKey={selectedMenuKey}
|
||||||
openKeys={openKeys}
|
openKeys={openKeys}
|
||||||
onOpenChange={setOpenKeys}
|
onOpenChange={setOpenKeys}
|
||||||
items={menuItems}
|
onSelect={handleMenuClick}
|
||||||
onClick={handleMenuClick}
|
onSelectFolder={(key) => selectFolder(key, { syncUrl: true })}
|
||||||
className="file-tree"
|
getContextMenuItems={getNodeMenuItems}
|
||||||
|
showOtherFiles
|
||||||
|
emptyText="暂无文档"
|
||||||
/>
|
/>
|
||||||
</Sider>
|
</Sider>
|
||||||
|
|
||||||
<Content className="document-content">
|
<Content className="document-content">
|
||||||
<div className="content-header">
|
<div className="content-header">
|
||||||
<h3 className="preview-header-title">
|
{renderContentBreadcrumb()}
|
||||||
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
|
{isPdfSelected ? (
|
||||||
<span className="preview-header-text">{headerLabel}</span>
|
// PDF 只读预览,不提供保存/重置(工具栏由 VirtualPDFViewer 注入)
|
||||||
</h3>
|
<div className="pdf-header-toolbar" ref={setPdfToolbarTarget} />
|
||||||
<Space>
|
) : selectedFile ? (
|
||||||
<Button
|
<Space>
|
||||||
type="primary"
|
<Button
|
||||||
icon={<SaveOutlined />}
|
type="primary"
|
||||||
onClick={handleSaveFile}
|
icon={<SaveOutlined />}
|
||||||
loading={saving}
|
onClick={handleSaveFile}
|
||||||
disabled={!selectedFile}
|
loading={saving}
|
||||||
>
|
disabled={!selectedFile}
|
||||||
保存
|
>
|
||||||
</Button>
|
保存
|
||||||
<Button
|
</Button>
|
||||||
icon={<UndoOutlined />}
|
<Button
|
||||||
onClick={handleReset}
|
icon={<UndoOutlined />}
|
||||||
disabled={!selectedFile || loading}
|
onClick={handleReset}
|
||||||
>
|
disabled={!selectedFile || loading}
|
||||||
重置
|
>
|
||||||
</Button>
|
重置
|
||||||
</Space>
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="editor-container">
|
<div className="editor-container">
|
||||||
{isPdfSelected ? (
|
{isPdfSelected ? (
|
||||||
<div className="empty-editor">
|
<div className="pdf-editor-container">
|
||||||
<p>PDF文件请在浏览模式下查看</p>
|
<VirtualPDFViewer
|
||||||
|
url={pdfUrl}
|
||||||
|
filename={pdfFilename}
|
||||||
|
toolbarTarget={pdfToolbarTarget}
|
||||||
|
/>
|
||||||
</div>
|
</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 ? (
|
) : selectedFile ? (
|
||||||
<div
|
<div
|
||||||
className={`bytemd-wrapper ${isLargeMarkdown ? 'large-file-editor' : ''}`}
|
className={`bytemd-wrapper ${isLargeMarkdown ? 'large-file-editor' : ''}`}
|
||||||
|
|
|
||||||
|
|
@ -122,50 +122,6 @@
|
||||||
color: var(--link-color);
|
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 {
|
.docs-content-layout {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
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 { ShareAltOutlined, FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, CopyOutlined, CloudDownloadOutlined, CloudUploadOutlined, ArrowLeftOutlined, ReloadOutlined, VerticalAlignTopOutlined } from '@ant-design/icons'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
@ -21,6 +21,7 @@ import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||||||
import FloatingToc from '@/components/FloatingToc/FloatingToc'
|
import FloatingToc from '@/components/FloatingToc/FloatingToc'
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
|
import ModeSwitch from '@/components/ModeSwitch/ModeSwitch'
|
||||||
|
import ProjectFileTree from '@/components/ProjectFileTree/ProjectFileTree'
|
||||||
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
|
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
|
||||||
import './DocumentPage.css'
|
import './DocumentPage.css'
|
||||||
|
|
||||||
|
|
@ -503,48 +504,6 @@ function DocumentPage() {
|
||||||
return null
|
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 文件
|
// 加载 markdown 文件
|
||||||
const loadMarkdown = async (filePath) => {
|
const loadMarkdown = async (filePath) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -843,12 +802,20 @@ function DocumentPage() {
|
||||||
|
|
||||||
const handleGitPull = async (repoId = null, force = false, syncScope = null, subPath = '') => {
|
const handleGitPull = async (repoId = null, force = false, syncScope = null, subPath = '') => {
|
||||||
if (gitRepos.length === 0) {
|
if (gitRepos.length === 0) {
|
||||||
message.warning('未配置Git仓库')
|
Toast.warning('未配置Git仓库')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await gitPull(projectId, repoId, force, syncScope, subPath)
|
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
|
// Refresh tree
|
||||||
loadFileTree()
|
loadFileTree()
|
||||||
// Reload current file if open
|
// Reload current file if open
|
||||||
|
|
@ -883,19 +850,27 @@ function DocumentPage() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
message.error(errorMsg)
|
Toast.error(errorMsg)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGitPush = async (repoId = null, force = false, syncScope = null, subPath = '') => {
|
const handleGitPush = async (repoId = null, force = false, syncScope = null, subPath = '') => {
|
||||||
if (gitRepos.length === 0) {
|
if (gitRepos.length === 0) {
|
||||||
message.warning('未配置Git仓库')
|
Toast.warning('未配置Git仓库')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await gitPush(projectId, repoId, force, syncScope, subPath)
|
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
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Git Push error:', error)
|
console.error('Git Push error:', error)
|
||||||
|
|
@ -923,7 +898,7 @@ function DocumentPage() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
message.error(errorMsg)
|
Toast.error(errorMsg)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1050,10 +1025,10 @@ function DocumentPage() {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
try {
|
try {
|
||||||
await loadFileTree({ throwOnError: true })
|
await loadFileTree({ throwOnError: true })
|
||||||
message.success('已刷新')
|
Toast.success('已刷新')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Refresh documents error:', error)
|
console.error('Refresh documents error:', error)
|
||||||
message.error('刷新失败')
|
Toast.error('刷新失败')
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
}
|
}
|
||||||
|
|
@ -1076,7 +1051,7 @@ function DocumentPage() {
|
||||||
setShareModalVisible(true)
|
setShareModalVisible(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get share info error:', error)
|
console.error('Get share info error:', error)
|
||||||
message.error('获取分享信息失败')
|
Toast.error('获取分享信息失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1126,13 +1101,13 @@ function DocumentPage() {
|
||||||
}
|
}
|
||||||
setHasPassword(false)
|
setHasPassword(false)
|
||||||
setPassword('')
|
setPassword('')
|
||||||
message.success('已取消文件访问密码')
|
Toast.success('已取消文件访问密码')
|
||||||
await loadFileTree()
|
await loadFileTree()
|
||||||
const res = await getFileShareInfo(projectId, selectedNodeKey)
|
const res = await getFileShareInfo(projectId, selectedNodeKey)
|
||||||
setShareInfo(res.data)
|
setShareInfo(res.data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update settings error:', error)
|
console.error('Update settings error:', error)
|
||||||
message.error('操作失败')
|
Toast.error('操作失败')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setHasPassword(true)
|
setHasPassword(true)
|
||||||
|
|
@ -1142,12 +1117,12 @@ function DocumentPage() {
|
||||||
// 保存密码
|
// 保存密码
|
||||||
const handleSavePassword = async () => {
|
const handleSavePassword = async () => {
|
||||||
if (!selectedNodeKey) {
|
if (!selectedNodeKey) {
|
||||||
message.warning('请先选择文件')
|
Toast.warning('请先选择文件')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!password.trim()) {
|
if (!password.trim()) {
|
||||||
message.warning('请输入访问密码')
|
Toast.warning('请输入访问密码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
@ -1155,7 +1130,7 @@ function DocumentPage() {
|
||||||
file_path: selectedNodeKey,
|
file_path: selectedNodeKey,
|
||||||
access_pass: hasPassword ? password : null,
|
access_pass: hasPassword ? password : null,
|
||||||
})
|
})
|
||||||
message.success('文件分享已更新')
|
Toast.success('文件分享已更新')
|
||||||
setShareInfo(res.data)
|
setShareInfo(res.data)
|
||||||
await loadFileTree()
|
await loadFileTree()
|
||||||
const nextInfo = await getFileShareInfo(projectId, selectedNodeKey)
|
const nextInfo = await getFileShareInfo(projectId, selectedNodeKey)
|
||||||
|
|
@ -1163,13 +1138,13 @@ function DocumentPage() {
|
||||||
setHasPassword(Boolean(nextInfo.data?.has_password))
|
setHasPassword(Boolean(nextInfo.data?.has_password))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save password error:', error)
|
console.error('Save password error:', error)
|
||||||
message.error('设置文件分享失败')
|
Toast.error('设置文件分享失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateShare = async () => {
|
const handleCreateShare = async () => {
|
||||||
if (!selectedNodeKey) {
|
if (!selectedNodeKey) {
|
||||||
message.warning('请先选择文件')
|
Toast.warning('请先选择文件')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1181,10 +1156,10 @@ function DocumentPage() {
|
||||||
setShareInfo(res.data)
|
setShareInfo(res.data)
|
||||||
setHasPassword(Boolean(res.data?.has_password))
|
setHasPassword(Boolean(res.data?.has_password))
|
||||||
await loadFileTree()
|
await loadFileTree()
|
||||||
message.success('文件分享已创建')
|
Toast.success('文件分享已创建')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Create file share error:', error)
|
console.error('Create file share error:', error)
|
||||||
message.error('创建文件分享失败')
|
Toast.error('创建文件分享失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1197,14 +1172,14 @@ function DocumentPage() {
|
||||||
setHasPassword(false)
|
setHasPassword(false)
|
||||||
setPassword('')
|
setPassword('')
|
||||||
await loadFileTree()
|
await loadFileTree()
|
||||||
message.success('文件分享已关闭')
|
Toast.success('文件分享已关闭')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete file share error:', error)
|
console.error('Delete file share error:', error)
|
||||||
message.error('关闭文件分享失败')
|
Toast.error('关闭文件分享失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuItems = convertTreeToMenuItems(filteredTreeData)
|
|
||||||
|
|
||||||
// Markdown 内容高亮处理
|
// Markdown 内容高亮处理
|
||||||
// 使用 components 替换文本节点,但这只对直接文本子节点有效
|
// 使用 components 替换文本节点,但这只对直接文本子节点有效
|
||||||
|
|
@ -1295,6 +1270,7 @@ function DocumentPage() {
|
||||||
>
|
>
|
||||||
<ArrowLeftOutlined />
|
<ArrowLeftOutlined />
|
||||||
</button>
|
</button>
|
||||||
|
<h2 title={projectName || '项目浏览'}>{projectName || '项目浏览'}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="docs-sider-actions">
|
<div className="docs-sider-actions">
|
||||||
<div className="mode-actions-row">
|
<div className="mode-actions-row">
|
||||||
|
|
@ -1351,21 +1327,16 @@ function DocumentPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredTreeData.length > 0 ? (
|
<ProjectFileTree
|
||||||
<Menu
|
treeData={filteredTreeData}
|
||||||
mode="inline"
|
selectedKey={selectedNodeKey}
|
||||||
selectedKeys={selectedNodeKey ? [selectedNodeKey] : []}
|
openKeys={openKeys}
|
||||||
openKeys={openKeys}
|
onOpenChange={setOpenKeys}
|
||||||
onOpenChange={setOpenKeys}
|
onSelect={handleMenuClick}
|
||||||
items={menuItems}
|
onSelectFolder={(key) => selectFolder(key, { syncUrl: true })}
|
||||||
onClick={handleMenuClick}
|
showShareIcon
|
||||||
className="docs-menu"
|
emptyText="未找到匹配文档"
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-color-secondary)' }}>
|
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配文档" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Sider>
|
</Sider>
|
||||||
|
|
||||||
{/* 右侧内容区 */}
|
{/* 右侧内容区 */}
|
||||||
|
|
|
||||||
|
|
@ -114,36 +114,6 @@
|
||||||
padding: 12px 16px 4px;
|
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 {
|
.preview-content-layout {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -276,6 +246,164 @@
|
||||||
min-height: 400px;
|
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 {
|
.markdown-body {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|
@ -402,6 +530,24 @@
|
||||||
gap: 8px;
|
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 {
|
.file-share-content-header {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'
|
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 { FileTextOutlined, FolderOutlined, FolderOpenOutlined, FilePdfOutlined, LockOutlined, MenuOutlined, VerticalAlignTopOutlined, CloudDownloadOutlined, UnorderedListOutlined, ArrowLeftOutlined } from '@ant-design/icons'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
@ -13,6 +13,7 @@ import GithubSlugger from 'github-slugger'
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc'
|
import FloatingToc, { TocDrawer } from '@/components/FloatingToc/FloatingToc'
|
||||||
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
import VirtualPDFViewer from '@/components/PDFViewer/VirtualPDFViewer'
|
||||||
|
import ProjectFileTree from '@/components/ProjectFileTree/ProjectFileTree'
|
||||||
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
|
import LargeMarkdownViewer, { isLargeMarkdownContent } from '@/components/LargeMarkdownViewer/LargeMarkdownViewer'
|
||||||
import {
|
import {
|
||||||
getProjectSharePublicInfo,
|
getProjectSharePublicInfo,
|
||||||
|
|
@ -26,6 +27,7 @@ import {
|
||||||
import './PreviewPage.css'
|
import './PreviewPage.css'
|
||||||
|
|
||||||
const { Sider, Content } = Layout
|
const { Sider, Content } = Layout
|
||||||
|
const ROOT_FOLDER_KEY = '__root__'
|
||||||
|
|
||||||
const HighlightText = ({ text, keyword }) => {
|
const HighlightText = ({ text, keyword }) => {
|
||||||
if (!keyword || !text) return text
|
if (!keyword || !text) return text
|
||||||
|
|
@ -42,7 +44,7 @@ const HighlightText = ({ text, keyword }) => {
|
||||||
function ProjectSharePage() {
|
function ProjectSharePage() {
|
||||||
const { shareCode } = useParams()
|
const { shareCode } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [projectInfo, setProjectInfo] = useState(null)
|
const [projectInfo, setProjectInfo] = useState(null)
|
||||||
const [fileTree, setFileTree] = useState([])
|
const [fileTree, setFileTree] = useState([])
|
||||||
const [selectedFile, setSelectedFile] = useState('')
|
const [selectedFile, setSelectedFile] = useState('')
|
||||||
|
|
@ -106,15 +108,34 @@ function ProjectSharePage() {
|
||||||
if (fileTree.length === 0) return
|
if (fileTree.length === 0) return
|
||||||
|
|
||||||
const fileParam = searchParams.get('file')
|
const fileParam = searchParams.get('file')
|
||||||
if (fileParam) {
|
const selectedParam = searchParams.get('selected')
|
||||||
openSharedFile(fileParam)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedFile) {
|
// 文件夹导航(URL 带 selected 参数)
|
||||||
const readmeNode = findReadme(fileTree)
|
if (selectedParam) {
|
||||||
if (readmeNode) {
|
if (selectedParam === ROOT_FOLDER_KEY) {
|
||||||
openSharedFile(readmeNode.key)
|
if (selectedNodeKey !== ROOT_FOLDER_KEY) {
|
||||||
|
selectRootFolder({ syncUrl: false })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const targetNode = findNodeByKey(fileTree, selectedParam)
|
||||||
|
if (targetNode && !targetNode.isLeaf && selectedParam !== selectedNodeKey) {
|
||||||
|
selectFolder(selectedParam)
|
||||||
|
}
|
||||||
|
} else if (fileParam) {
|
||||||
|
const targetNode = findNodeByKey(fileTree, fileParam)
|
||||||
|
if (targetNode && !targetNode.isLeaf) {
|
||||||
|
selectFolder(fileParam, { syncUrl: true })
|
||||||
|
} else if (fileParam !== selectedFile) {
|
||||||
|
openSharedFile(fileParam, { syncUrl: false })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 没有指定文件时,默认打开根目录 README.md
|
||||||
|
if (!selectedFile && !selectedNodeKey) {
|
||||||
|
const readmeNode = findReadme(fileTree)
|
||||||
|
if (readmeNode) {
|
||||||
|
openSharedFile(readmeNode.key, { syncUrl: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [fileTree, searchParams])
|
}, [fileTree, searchParams])
|
||||||
|
|
@ -264,6 +285,188 @@ function ProjectSharePage() {
|
||||||
return null
|
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(() => {
|
const filteredTreeData = useMemo(() => {
|
||||||
if (!searchKeyword.trim()) return fileTree
|
if (!searchKeyword.trim()) return fileTree
|
||||||
|
|
||||||
|
|
@ -288,34 +491,6 @@ function ProjectSharePage() {
|
||||||
return loop(fileTree)
|
return loop(fileTree)
|
||||||
}, [fileTree, searchKeyword, matchedFilePaths])
|
}, [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) => {
|
const resolveRelativePath = (currentPath, relativePath) => {
|
||||||
if (relativePath.startsWith('/')) return relativePath.substring(1)
|
if (relativePath.startsWith('/')) return relativePath.substring(1)
|
||||||
const lastSlashIndex = currentPath.lastIndexOf('/')
|
const lastSlashIndex = currentPath.lastIndexOf('/')
|
||||||
|
|
@ -337,19 +512,13 @@ function ProjectSharePage() {
|
||||||
return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath
|
return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath
|
||||||
}
|
}
|
||||||
|
|
||||||
const openSharedFile = (key) => {
|
const openSharedFile = (key, { syncUrl = true } = {}) => {
|
||||||
setSelectedFile(key)
|
setSelectedFile(key)
|
||||||
setSelectedNodeKey(key)
|
setSelectedNodeKey(key)
|
||||||
|
expandParentFolders(key)
|
||||||
|
|
||||||
const parts = key.split('/')
|
if (syncUrl) {
|
||||||
const allParentPaths = []
|
updateFileParam(key)
|
||||||
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 (key.toLowerCase().endsWith('.pdf')) {
|
if (key.toLowerCase().endsWith('.pdf')) {
|
||||||
|
|
@ -399,11 +568,6 @@ function ProjectSharePage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuItems = useMemo(
|
|
||||||
() => convertTreeToMenuItems(filteredTreeData),
|
|
||||||
[filteredTreeData, openKeys]
|
|
||||||
)
|
|
||||||
|
|
||||||
const markdownComponents = {
|
const markdownComponents = {
|
||||||
a: ({ node, href, children, ...props }) => {
|
a: ({ node, href, children, ...props }) => {
|
||||||
const isExternal = isExternalHref(href)
|
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 (
|
return (
|
||||||
<div className="preview-page">
|
<div className="preview-page">
|
||||||
<Layout className="preview-layout">
|
<Layout className="preview-layout">
|
||||||
|
|
@ -448,22 +608,21 @@ function ProjectSharePage() {
|
||||||
enterButton
|
enterButton
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{menuItems.length > 0 ? (
|
<ProjectFileTree
|
||||||
<Menu
|
treeData={filteredTreeData}
|
||||||
mode="inline"
|
selectedKey={selectedNodeKey}
|
||||||
selectedKeys={selectedNodeKey ? [selectedNodeKey] : []}
|
openKeys={openKeys}
|
||||||
openKeys={openKeys}
|
onOpenChange={setOpenKeys}
|
||||||
onOpenChange={setOpenKeys}
|
onSelect={(info) => {
|
||||||
items={menuItems}
|
handleMenuClick(info)
|
||||||
onClick={({ key }) => {
|
setMobileDrawerVisible(false)
|
||||||
openSharedFile(key)
|
}}
|
||||||
setMobileDrawerVisible(false)
|
onSelectFolder={(key) => {
|
||||||
}}
|
selectFolder(key, { syncUrl: true })
|
||||||
className="preview-menu"
|
setMobileDrawerVisible(false)
|
||||||
/>
|
}}
|
||||||
) : (
|
emptyText="暂无文档"
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无文档" />
|
/>
|
||||||
)}
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -492,21 +651,15 @@ function ProjectSharePage() {
|
||||||
enterButton
|
enterButton
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{menuItems.length > 0 ? (
|
<ProjectFileTree
|
||||||
<Menu
|
treeData={filteredTreeData}
|
||||||
mode="inline"
|
selectedKey={selectedNodeKey}
|
||||||
selectedKeys={selectedNodeKey ? [selectedNodeKey] : []}
|
openKeys={openKeys}
|
||||||
openKeys={openKeys}
|
onOpenChange={setOpenKeys}
|
||||||
onOpenChange={setOpenKeys}
|
onSelect={handleMenuClick}
|
||||||
items={menuItems}
|
onSelectFolder={(key) => selectFolder(key, { syncUrl: true })}
|
||||||
onClick={({ key }) => openSharedFile(key)}
|
emptyText="暂无文档"
|
||||||
className="preview-menu"
|
/>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div style={{ padding: 20 }}>
|
|
||||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无文档" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Sider>
|
</Sider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -535,10 +688,7 @@ function ProjectSharePage() {
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h3 className="preview-header-title">
|
{renderContentBreadcrumb()}
|
||||||
<HeaderIcon className="preview-header-icon" style={isHeaderPdf ? { color: '#f5222d' } : undefined} />
|
|
||||||
<span className="preview-header-text">{headerLabel}</span>
|
|
||||||
</h3>
|
|
||||||
{viewMode === 'markdown' && (
|
{viewMode === 'markdown' && (
|
||||||
isMobile ? (
|
isMobile ? (
|
||||||
<Space className="preview-header-actions preview-compact-actions" size={4}>
|
<Space className="preview-header-actions preview-compact-actions" size={4}>
|
||||||
|
|
@ -602,6 +752,63 @@ function ProjectSharePage() {
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === 'pdf' ? (
|
) : viewMode === 'pdf' ? (
|
||||||
<VirtualPDFViewer url={pdfUrl} filename={pdfFilename} toolbarTarget={pdfToolbarTarget} compactToolbar={isMobile} />
|
<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 ? (
|
) : isLargeMarkdown ? (
|
||||||
<LargeMarkdownViewer
|
<LargeMarkdownViewer
|
||||||
ref={largeMarkdownRef}
|
ref={largeMarkdownRef}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from 'react'
|
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 { UserOutlined, LockOutlined, UploadOutlined, ApiOutlined, CopyOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||||
import ImgCrop from 'antd-img-crop'
|
import ImgCrop from 'antd-img-crop'
|
||||||
import { getCurrentUser, updateProfile, changePassword, uploadAvatar, getMcpCredentials, rotateMcpSecret } from '@/api/auth'
|
import { getCurrentUser, updateProfile, changePassword, uploadAvatar, getMcpCredentials, rotateMcpSecret } from '@/api/auth'
|
||||||
|
|
@ -35,7 +35,7 @@ function ProfilePage() {
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Load user info error:', error)
|
console.error('Load user info error:', error)
|
||||||
message.error('加载用户信息失败')
|
Toast.error('加载用户信息失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,7 +63,7 @@ function ProfilePage() {
|
||||||
Toast.success('更新成功', '个人资料已更新')
|
Toast.success('更新成功', '个人资料已更新')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update profile error:', error)
|
console.error('Update profile error:', error)
|
||||||
message.error(error.response?.data?.detail || '更新失败')
|
Toast.error(error.response?.data?.detail || '更新失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +85,7 @@ function ProfilePage() {
|
||||||
}, 2000)
|
}, 2000)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Change password error:', error)
|
console.error('Change password error:', error)
|
||||||
message.error(error.response?.data?.detail || '修改密码失败')
|
Toast.error(error.response?.data?.detail || '修改密码失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 { 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 { getProjectShareInfo, updateProjectShareSettings } from '@/api/share'
|
||||||
import { getProjectTree } from '@/api/file'
|
|
||||||
import { getUserList } from '@/api/users'
|
import { getUserList } from '@/api/users'
|
||||||
import { searchDocuments } from '@/api/search'
|
import { searchDocuments } from '@/api/search'
|
||||||
import { getUnreadByProject, markProjectNotificationsRead } from '@/api/notification'
|
import { getUnreadByProject, markProjectNotificationsRead } from '@/api/notification'
|
||||||
|
|
@ -146,7 +145,7 @@ function ProjectList({ type = 'my' }) {
|
||||||
// 过滤掉当前所有者(也就是自己,虽然API也会校验)
|
// 过滤掉当前所有者(也就是自己,虽然API也会校验)
|
||||||
setUsers(allUsers.filter(u => u.id !== currentProject.owner_id))
|
setUsers(allUsers.filter(u => u.id !== currentProject.owner_id))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('加载用户列表失败')
|
Toast.error('加载用户列表失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMembers(false)
|
setLoadingMembers(false)
|
||||||
}
|
}
|
||||||
|
|
@ -161,14 +160,14 @@ function ProjectList({ type = 'my' }) {
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
await transferProject(currentProject.id, values.new_owner_id)
|
await transferProject(currentProject.id, values.new_owner_id)
|
||||||
message.success('项目所有权已转移')
|
Toast.success('项目所有权已转移')
|
||||||
setTransferModalVisible(false)
|
setTransferModalVisible(false)
|
||||||
setEditModalVisible(false)
|
setEditModalVisible(false)
|
||||||
transferForm.resetFields()
|
transferForm.resetFields()
|
||||||
fetchProjects()
|
fetchProjects()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Transfer error:', 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,
|
is_public: checked ? 1 : 0,
|
||||||
}
|
}
|
||||||
setCurrentProject(updatedProject)
|
setCurrentProject(updatedProject)
|
||||||
message.success(checked ? '项目已设为公开' : '项目已设为私有')
|
Toast.success(checked ? '项目已设为公开' : '项目已设为私有')
|
||||||
await fetchProjects()
|
await fetchProjects()
|
||||||
|
|
||||||
if (checked) {
|
if (checked) {
|
||||||
|
|
@ -275,7 +274,7 @@ function ProjectList({ type = 'my' }) {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Toggle public error:', error)
|
console.error('Toggle public error:', error)
|
||||||
message.error('操作失败')
|
Toast.error('操作失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -291,13 +290,13 @@ function ProjectList({ type = 'my' }) {
|
||||||
...values,
|
...values,
|
||||||
}
|
}
|
||||||
setCurrentProject(updatedProject)
|
setCurrentProject(updatedProject)
|
||||||
message.success('项目更新成功')
|
Toast.success('项目更新成功')
|
||||||
await fetchProjects()
|
await fetchProjects()
|
||||||
setEditModalVisible(false)
|
setEditModalVisible(false)
|
||||||
editForm.resetFields()
|
editForm.resetFields()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update project error:', 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 [loadingRepos, setLoadingRepos] = useState(false)
|
||||||
const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false)
|
const [gitRepoModalVisible, setGitRepoModalVisible] = useState(false)
|
||||||
const [editingRepo, setEditingRepo] = useState(null)
|
const [editingRepo, setEditingRepo] = useState(null)
|
||||||
const [gitDirOptions, setGitDirOptions] = useState([])
|
const [testingGitRepo, setTestingGitRepo] = useState(false)
|
||||||
const [repoForm] = Form.useForm()
|
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设置(仓库列表)
|
// 打开Git设置(仓库列表)
|
||||||
const handleGitSettings = (e, project) => {
|
const handleGitSettings = (e, project) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
@ -388,7 +352,7 @@ function ProjectList({ type = 'my' }) {
|
||||||
setGitRepos(res.data || [])
|
setGitRepos(res.data || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch git repos error:', error)
|
console.error('Fetch git repos error:', error)
|
||||||
message.error('加载Git仓库失败')
|
Toast.error('加载Git仓库失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingRepos(false)
|
setLoadingRepos(false)
|
||||||
}
|
}
|
||||||
|
|
@ -398,15 +362,11 @@ function ProjectList({ type = 'my' }) {
|
||||||
const handleAddRepo = () => {
|
const handleAddRepo = () => {
|
||||||
setEditingRepo(null)
|
setEditingRepo(null)
|
||||||
repoForm.resetFields()
|
repoForm.resetFields()
|
||||||
setGitDirOptions([])
|
|
||||||
// 如果是第一个仓库,默认设为默认
|
// 如果是第一个仓库,默认设为默认
|
||||||
if (gitRepos.length === 0) {
|
if (gitRepos.length === 0) {
|
||||||
repoForm.setFieldsValue({ is_default: 1 })
|
repoForm.setFieldsValue({ is_default: 1 })
|
||||||
}
|
}
|
||||||
setGitRepoModalVisible(true)
|
setGitRepoModalVisible(true)
|
||||||
if (currentProject) {
|
|
||||||
loadLocalGitDirOptions(currentProject.id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开编辑仓库弹窗
|
// 打开编辑仓库弹窗
|
||||||
|
|
@ -415,13 +375,25 @@ function ProjectList({ type = 'my' }) {
|
||||||
repoForm.setFieldsValue({
|
repoForm.setFieldsValue({
|
||||||
...repo,
|
...repo,
|
||||||
is_default: repo.is_default === 1,
|
is_default: repo.is_default === 1,
|
||||||
sync_path: repo.sync_path || undefined,
|
|
||||||
})
|
})
|
||||||
setGitRepoModalVisible(true)
|
setGitRepoModalVisible(true)
|
||||||
// 先用本地目录立即渲染,再后台按需加载远端目录结构。
|
}
|
||||||
if (currentProject) {
|
|
||||||
loadLocalGitDirOptions(currentProject.id)
|
const handleTestRepoConnection = async () => {
|
||||||
.then(() => loadRemoteGitDirOptions(currentProject.id, repo.id))
|
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 () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
await deleteGitRepo(currentProject.id, repoId)
|
await deleteGitRepo(currentProject.id, repoId)
|
||||||
message.success('删除成功')
|
Toast.success('删除成功')
|
||||||
fetchGitRepos(currentProject.id)
|
fetchGitRepos(currentProject.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete repo error:', 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 = {
|
const data = {
|
||||||
...values,
|
...values,
|
||||||
is_default: values.is_default ? 1 : 0,
|
is_default: values.is_default ? 1 : 0,
|
||||||
sync_path: values.sync_path || '',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingRepo) {
|
if (editingRepo) {
|
||||||
await updateGitRepo(currentProject.id, editingRepo.id, data)
|
await updateGitRepo(currentProject.id, editingRepo.id, data)
|
||||||
message.success('更新成功')
|
Toast.success('更新成功')
|
||||||
} else {
|
} else {
|
||||||
await createGitRepo(currentProject.id, data)
|
await createGitRepo(currentProject.id, data)
|
||||||
message.success('添加成功')
|
Toast.success('添加成功')
|
||||||
}
|
}
|
||||||
|
|
||||||
setGitRepoModalVisible(false)
|
setGitRepoModalVisible(false)
|
||||||
fetchGitRepos(currentProject.id)
|
fetchGitRepos(currentProject.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save repo error:', 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 })
|
await updateProjectShareSettings(currentProject.id, { access_pass: null })
|
||||||
setHasPassword(false)
|
setHasPassword(false)
|
||||||
setPassword('')
|
setPassword('')
|
||||||
message.success('已取消访问密码')
|
Toast.success('已取消访问密码')
|
||||||
const res = await getProjectShareInfo(currentProject.id)
|
const res = await getProjectShareInfo(currentProject.id)
|
||||||
setShareInfo(res.data)
|
setShareInfo(res.data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update settings error:', error)
|
console.error('Update settings error:', error)
|
||||||
message.error('操作失败')
|
Toast.error('操作失败')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setHasPassword(true)
|
setHasPassword(true)
|
||||||
|
|
@ -541,18 +512,18 @@ function ProjectList({ type = 'my' }) {
|
||||||
// 保存密码
|
// 保存密码
|
||||||
const handleSavePassword = async () => {
|
const handleSavePassword = async () => {
|
||||||
if (!password.trim()) {
|
if (!password.trim()) {
|
||||||
message.warning('请输入访问密码')
|
Toast.warning('请输入访问密码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await updateProjectShareSettings(currentProject.id, { access_pass: password })
|
await updateProjectShareSettings(currentProject.id, { access_pass: password })
|
||||||
message.success('访问密码已设置')
|
Toast.success('访问密码已设置')
|
||||||
const res = await getProjectShareInfo(currentProject.id)
|
const res = await getProjectShareInfo(currentProject.id)
|
||||||
setShareInfo(res.data)
|
setShareInfo(res.data)
|
||||||
setHasPassword(true)
|
setHasPassword(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save password error:', error)
|
console.error('Save password error:', error)
|
||||||
message.error('设置密码失败')
|
Toast.error('设置密码失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -574,7 +545,7 @@ function ProjectList({ type = 'my' }) {
|
||||||
console.log('Users Response:', usersRes)
|
console.log('Users Response:', usersRes)
|
||||||
|
|
||||||
const membersData = membersRes.data || []
|
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 : []
|
const usersData = Array.isArray(usersRes.data) ? usersRes.data : []
|
||||||
|
|
||||||
console.log('Setting members:', membersData)
|
console.log('Setting members:', membersData)
|
||||||
|
|
@ -585,7 +556,7 @@ function ProjectList({ type = 'my' }) {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get members error:', error)
|
console.error('Get members error:', error)
|
||||||
console.error('Error details:', error.response)
|
console.error('Error details:', error.response)
|
||||||
message.error('获取数据失败: ' + (error.response?.data?.detail || error.message))
|
Toast.error('获取数据失败: ' + (error.response?.data?.detail || error.message))
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMembers(false)
|
setLoadingMembers(false)
|
||||||
}
|
}
|
||||||
|
|
@ -595,7 +566,7 @@ function ProjectList({ type = 'my' }) {
|
||||||
const handleAddMember = async (values) => {
|
const handleAddMember = async (values) => {
|
||||||
try {
|
try {
|
||||||
await addProjectMember(currentProject.id, values)
|
await addProjectMember(currentProject.id, values)
|
||||||
message.success('成员添加成功')
|
Toast.success('成员添加成功')
|
||||||
memberForm.resetFields()
|
memberForm.resetFields()
|
||||||
// 刷新成员列表(带用户名信息)
|
// 刷新成员列表(带用户名信息)
|
||||||
const res = await getProjectMembers(currentProject.id)
|
const res = await getProjectMembers(currentProject.id)
|
||||||
|
|
@ -603,7 +574,7 @@ function ProjectList({ type = 'my' }) {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Add member error:', error)
|
console.error('Add member error:', error)
|
||||||
const errorMsg = error.response?.data?.detail || error.message || '添加成员失败'
|
const errorMsg = error.response?.data?.detail || error.message || '添加成员失败'
|
||||||
message.error(errorMsg)
|
Toast.error(errorMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -615,14 +586,14 @@ function ProjectList({ type = 'my' }) {
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
await removeProjectMember(currentProject.id, userId)
|
await removeProjectMember(currentProject.id, userId)
|
||||||
message.success('成员删除成功')
|
Toast.success('成员删除成功')
|
||||||
// 刷新成员列表(带用户名信息)
|
// 刷新成员列表(带用户名信息)
|
||||||
const res = await getProjectMembers(currentProject.id)
|
const res = await getProjectMembers(currentProject.id)
|
||||||
setMembers(res.data || [])
|
setMembers(res.data || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Remove member error:', error)
|
console.error('Remove member error:', error)
|
||||||
const errorMsg = error.response?.data?.detail || error.message || '删除成员失败'
|
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访问令牌" />
|
<Input.Password placeholder="Git访问令牌" />
|
||||||
</Form.Item>
|
</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
|
<Form.Item
|
||||||
name="is_default"
|
name="is_default"
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
|
|
@ -1369,6 +1321,9 @@ function ProjectList({ type = 'my' }) {
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||||
<Button onClick={() => setGitRepoModalVisible(false)}>取消</Button>
|
<Button onClick={() => setGitRepoModalVisible(false)}>取消</Button>
|
||||||
|
<Button loading={testingGitRepo} onClick={handleTestRepoConnection}>
|
||||||
|
测试远端认证
|
||||||
|
</Button>
|
||||||
<Button type="primary" htmlType="submit">保存</Button>
|
<Button type="primary" htmlType="submit">保存</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from 'react'
|
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 { addProjectMember, getProjectMembers, removeProjectMember, transferProject } from '@/api/project'
|
||||||
import { getUserList } from '@/api/users'
|
import { getUserList } from '@/api/users'
|
||||||
|
|
||||||
|
|
@ -18,7 +19,7 @@ function useProjectCollaboration() {
|
||||||
const allUsers = res.data || []
|
const allUsers = res.data || []
|
||||||
setUsers(allUsers.filter((user) => user.id !== project.owner_id))
|
setUsers(allUsers.filter((user) => user.id !== project.owner_id))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('加载用户列表失败')
|
Toast.error('加载用户列表失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMembers(false)
|
setLoadingMembers(false)
|
||||||
}
|
}
|
||||||
|
|
@ -33,12 +34,12 @@ function useProjectCollaboration() {
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
await transferProject(projectId, newOwnerId)
|
await transferProject(projectId, newOwnerId)
|
||||||
message.success('项目所有权已转移')
|
Toast.success('项目所有权已转移')
|
||||||
setTransferModalVisible(false)
|
setTransferModalVisible(false)
|
||||||
onCompleted?.()
|
onCompleted?.()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Transfer error:', 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) {
|
} catch (error) {
|
||||||
console.error('Get members error:', error)
|
console.error('Get members error:', error)
|
||||||
console.error('Error details:', error.response)
|
console.error('Error details:', error.response)
|
||||||
message.error(`获取数据失败: ${error.response?.data?.detail || error.message}`)
|
Toast.error(`获取数据失败: ${error.response?.data?.detail || error.message}`)
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMembers(false)
|
setLoadingMembers(false)
|
||||||
}
|
}
|
||||||
|
|
@ -78,13 +79,13 @@ function useProjectCollaboration() {
|
||||||
const addMember = async (projectId, values) => {
|
const addMember = async (projectId, values) => {
|
||||||
try {
|
try {
|
||||||
await addProjectMember(projectId, values)
|
await addProjectMember(projectId, values)
|
||||||
message.success('成员添加成功')
|
Toast.success('成员添加成功')
|
||||||
const res = await getProjectMembers(projectId)
|
const res = await getProjectMembers(projectId)
|
||||||
setMembers(res.data || [])
|
setMembers(res.data || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Add member error:', error)
|
console.error('Add member error:', error)
|
||||||
const errorMsg = error.response?.data?.detail || error.message || '添加成员失败'
|
const errorMsg = error.response?.data?.detail || error.message || '添加成员失败'
|
||||||
message.error(errorMsg)
|
Toast.error(errorMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,13 +96,13 @@ function useProjectCollaboration() {
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
await removeProjectMember(projectId, userId)
|
await removeProjectMember(projectId, userId)
|
||||||
message.success('成员删除成功')
|
Toast.success('成员删除成功')
|
||||||
const res = await getProjectMembers(projectId)
|
const res = await getProjectMembers(projectId)
|
||||||
setMembers(res.data || [])
|
setMembers(res.data || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Remove member error:', error)
|
console.error('Remove member error:', error)
|
||||||
const errorMsg = error.response?.data?.detail || error.message || '删除成员失败'
|
const errorMsg = error.response?.data?.detail || error.message || '删除成员失败'
|
||||||
message.error(errorMsg)
|
Toast.error(errorMsg)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from 'react'
|
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'
|
import { createGitRepo, deleteGitRepo, getGitRepos, updateGitRepo } from '@/api/project'
|
||||||
|
|
||||||
function useProjectGitRepos() {
|
function useProjectGitRepos() {
|
||||||
|
|
@ -16,7 +17,7 @@ function useProjectGitRepos() {
|
||||||
setGitRepos(res.data || [])
|
setGitRepos(res.data || [])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch git repos error:', error)
|
console.error('Fetch git repos error:', error)
|
||||||
message.error('加载Git仓库失败')
|
Toast.error('加载Git仓库失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingRepos(false)
|
setLoadingRepos(false)
|
||||||
}
|
}
|
||||||
|
|
@ -62,11 +63,11 @@ function useProjectGitRepos() {
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
try {
|
try {
|
||||||
await deleteGitRepo(projectId, repoId)
|
await deleteGitRepo(projectId, repoId)
|
||||||
message.success('删除成功')
|
Toast.success('删除成功')
|
||||||
await fetchGitRepos(projectId)
|
await fetchGitRepos(projectId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete repo error:', error)
|
console.error('Delete repo error:', error)
|
||||||
message.error('删除失败')
|
Toast.error('删除失败')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -81,17 +82,17 @@ function useProjectGitRepos() {
|
||||||
|
|
||||||
if (editingRepo) {
|
if (editingRepo) {
|
||||||
await updateGitRepo(projectId, editingRepo.id, data)
|
await updateGitRepo(projectId, editingRepo.id, data)
|
||||||
message.success('更新成功')
|
Toast.success('更新成功')
|
||||||
} else {
|
} else {
|
||||||
await createGitRepo(projectId, data)
|
await createGitRepo(projectId, data)
|
||||||
message.success('添加成功')
|
Toast.success('添加成功')
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchGitRepos(projectId)
|
await fetchGitRepos(projectId)
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save repo error:', error)
|
console.error('Save repo error:', error)
|
||||||
message.error(editingRepo ? '更新失败' : '添加失败')
|
Toast.error(editingRepo ? '更新失败' : '添加失败')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { message } from 'antd'
|
|
||||||
import { getLatestVectorizeTask, getVectorizeProgress, getVectorizeTask, vectorizeProject } from '@/api/knowledgeBase'
|
import { getLatestVectorizeTask, getVectorizeProgress, getVectorizeTask, vectorizeProject } from '@/api/knowledgeBase'
|
||||||
|
import Toast from '@/components/Toast/Toast'
|
||||||
|
|
||||||
function useProjectKnowledge() {
|
function useProjectKnowledge() {
|
||||||
const [kbModalVisible, setKbModalVisible] = useState(false)
|
const [kbModalVisible, setKbModalVisible] = useState(false)
|
||||||
|
|
@ -28,7 +28,7 @@ function useProjectKnowledge() {
|
||||||
return res.data
|
return res.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch vectorize progress error:', error)
|
console.error('Fetch vectorize progress error:', error)
|
||||||
if (!silent) message.error('加载向量化进度失败')
|
if (!silent) Toast.error('加载向量化进度失败')
|
||||||
return null
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) setLoadingProgress(false)
|
if (!silent) setLoadingProgress(false)
|
||||||
|
|
@ -64,13 +64,13 @@ function useProjectKnowledge() {
|
||||||
setVectorizing(false)
|
setVectorizing(false)
|
||||||
await fetchProgress(projectId, { silent: true })
|
await fetchProgress(projectId, { silent: true })
|
||||||
if (task?.status === 'success') {
|
if (task?.status === 'success') {
|
||||||
message.success(
|
Toast.success(
|
||||||
`向量化完成:处理 ${task.processed || 0} 个` +
|
`向量化完成:处理 ${task.processed || 0} 个` +
|
||||||
`${task.skipped ? `,跳过 ${task.skipped} 个` : ''}` +
|
`${task.skipped ? `,跳过 ${task.skipped} 个` : ''}` +
|
||||||
`${task.failed ? `,失败 ${task.failed} 个` : ''}`
|
`${task.failed ? `,失败 ${task.failed} 个` : ''}`
|
||||||
)
|
)
|
||||||
} else if (task?.status === 'failed') {
|
} else if (task?.status === 'failed') {
|
||||||
message.error(task.error_message || '向量化任务失败')
|
Toast.error(task.error_message || '向量化任务失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -109,7 +109,7 @@ function useProjectKnowledge() {
|
||||||
const res = await vectorizeProject(projectId, force)
|
const res = await vectorizeProject(projectId, force)
|
||||||
const task = res.data || null
|
const task = res.data || null
|
||||||
setCurrentTask(task)
|
setCurrentTask(task)
|
||||||
message.success('向量化任务已提交,正在后台执行')
|
Toast.success('向量化任务已提交,正在后台执行')
|
||||||
await fetchProgress(projectId)
|
await fetchProgress(projectId)
|
||||||
if (task?.task_id) {
|
if (task?.task_id) {
|
||||||
pollTask(projectId, task.task_id)
|
pollTask(projectId, task.task_id)
|
||||||
|
|
@ -117,7 +117,7 @@ function useProjectKnowledge() {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Vectorize project error:', error)
|
console.error('Vectorize project error:', error)
|
||||||
const detail = error.response?.data?.detail || '向量化失败'
|
const detail = error.response?.data?.detail || '向量化失败'
|
||||||
message.error(detail)
|
Toast.error(detail)
|
||||||
await fetchProgress(projectId, { silent: true })
|
await fetchProgress(projectId, { silent: true })
|
||||||
} finally {
|
} finally {
|
||||||
if (!pollTimer.current) {
|
if (!pollTimer.current) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { message } from 'antd'
|
|
||||||
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
import { getProjectShareInfo, updateShareSettings } from '@/api/share'
|
||||||
import Toast from '@/components/Toast/Toast'
|
import Toast from '@/components/Toast/Toast'
|
||||||
import { copyText } from '@/utils/browserIO'
|
import { copyText } from '@/utils/browserIO'
|
||||||
|
|
@ -19,7 +18,7 @@ function useProjectShare({ type }) {
|
||||||
setShareModalVisible(true)
|
setShareModalVisible(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get share info error:', 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 })
|
await updateShareSettings(projectId, { access_pass: null })
|
||||||
setHasPassword(false)
|
setHasPassword(false)
|
||||||
setPassword('')
|
setPassword('')
|
||||||
message.success('已取消访问密码')
|
Toast.success('已取消访问密码')
|
||||||
await refreshShareInfo(projectId)
|
await refreshShareInfo(projectId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update settings error:', error)
|
console.error('Update settings error:', error)
|
||||||
message.error('操作失败')
|
Toast.error('操作失败')
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -67,18 +66,18 @@ function useProjectShare({ type }) {
|
||||||
|
|
||||||
const savePassword = async (projectId) => {
|
const savePassword = async (projectId) => {
|
||||||
if (!password.trim()) {
|
if (!password.trim()) {
|
||||||
message.warning('请输入访问密码')
|
Toast.warning('请输入访问密码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateShareSettings(projectId, { access_pass: password })
|
await updateShareSettings(projectId, { access_pass: password })
|
||||||
message.success('访问密码已设置')
|
Toast.success('访问密码已设置')
|
||||||
await refreshShareInfo(projectId)
|
await refreshShareInfo(projectId)
|
||||||
setHasPassword(true)
|
setHasPassword(true)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Save password error:', error)
|
console.error('Save password error:', error)
|
||||||
message.error('设置密码失败')
|
Toast.error('设置密码失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useEffect } from 'react'
|
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 { ReloadOutlined, SearchOutlined, FileSearchOutlined, CheckCircleOutlined, FolderOutlined, FileTextOutlined } from '@ant-design/icons'
|
||||||
import { getOperationLogs, getLogStats } from '@/api/logs'
|
import { getOperationLogs, getLogStats } from '@/api/logs'
|
||||||
import { getUserList } from '@/api/users'
|
import { getUserList } from '@/api/users'
|
||||||
|
|
@ -128,7 +129,7 @@ function SystemLogs() {
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch logs error:', error)
|
console.error('Fetch logs error:', error)
|
||||||
message.error('获取日志失败')
|
Toast.error('获取日志失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -146,12 +147,12 @@ function SystemLogs() {
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getUserList({ page: 1, page_size: 100, status: 1 })
|
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 : []
|
const usersData = Array.isArray(res.data) ? res.data : []
|
||||||
setUsers(usersData)
|
setUsers(usersData)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch users error:', error)
|
console.error('Fetch users error:', error)
|
||||||
message.error('获取用户列表失败')
|
Toast.error('获取用户列表失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,38 +72,46 @@ request.interceptors.response.use(
|
||||||
console.error('Response error:', error)
|
console.error('Response error:', error)
|
||||||
console.error('Response error detail:', error.response)
|
console.error('Response error detail:', error.response)
|
||||||
|
|
||||||
|
// 业务层自行提示的错误(skipErrorToast),不再重复弹 Toast,避免出现两个提示窗
|
||||||
|
const skipErrorToast = error.config?.skipErrorToast === true
|
||||||
|
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
const { status, data } = error.response
|
const { status, data } = error.response
|
||||||
|
|
||||||
switch (status) {
|
// 401 始终走会话过期处理(清空凭证 + 跳转登录页)
|
||||||
case 401:
|
if (status === 401) {
|
||||||
// 只有不在登录页时才显示Toast和跳转
|
if (!window.location.pathname.includes('/login') && !isHandlingUnauthorized) {
|
||||||
if (!window.location.pathname.includes('/login') && !isHandlingUnauthorized) {
|
isHandlingUnauthorized = true
|
||||||
isHandlingUnauthorized = true
|
Toast.muteErrors(2500)
|
||||||
Toast.muteErrors(2500)
|
Toast.error('认证失败', data?.detail || '未登录或登录已过期', 3, { force: true })
|
||||||
Toast.error('认证失败', data?.detail || '未登录或登录已过期', 3, { force: true })
|
clearAuthStorage()
|
||||||
clearAuthStorage()
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
redirectToLoginWithReturnTo()
|
||||||
redirectToLoginWithReturnTo()
|
}, 600) // 延迟一小段时间,让提示先展示
|
||||||
}, 600) // 延迟一小段时间,让提示先展示
|
}
|
||||||
}
|
} else if (!skipErrorToast) {
|
||||||
break
|
switch (status) {
|
||||||
case 403:
|
case 403:
|
||||||
Toast.error('权限不足', data?.detail || '没有权限访问')
|
Toast.error('权限不足', data?.detail || '没有权限访问')
|
||||||
break
|
break
|
||||||
case 404:
|
case 404:
|
||||||
Toast.error('资源不存在', data?.detail || '请求的资源不存在')
|
Toast.error('资源不存在', data?.detail || '请求的资源不存在')
|
||||||
break
|
break
|
||||||
case 500:
|
case 500:
|
||||||
Toast.error('服务器错误', data?.detail || '服务器内部错误')
|
Toast.error('服务器错误', data?.detail || '服务器内部错误')
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
Toast.error('请求失败', data?.detail || data?.message || '请求失败')
|
Toast.error('请求失败', data?.detail || data?.message || '请求失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (error.request) {
|
} else if (error.request) {
|
||||||
Toast.error('网络错误', '请检查网络连接')
|
if (!skipErrorToast) {
|
||||||
|
Toast.error('网络错误', '请检查网络连接')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Toast.error('请求错误', error.message || '请求配置错误')
|
if (!skipErrorToast) {
|
||||||
|
Toast.error('请求错误', error.message || '请求配置错误')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue