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