解决编辑模式的问题

main
mula.liu 2026-10-01 15:21:38 +08:00
parent ba80d280d1
commit 6831bd4831
177 changed files with 5652 additions and 18246 deletions

View File

@ -1,496 +0,0 @@
// Native client bundle for @nex/pet-dock
// Registered into shell.overlay + settings.section.
// Plain CJS factory consumed by the browser ModuleLoader; only `react` (a platform seed) is required.
window.__ModuleLoader__.load({
id: "@nex/pet-dock",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
let react = require("react");
// ---- stylesheet -------------------------------------------------------
var cssId = "@nex/pet-dock/pet.css";
function claimStyles() {
if (typeof document === "undefined") return;
if (document.querySelector('style[data-plugin-css="' + cssId + '"]')) return;
var tag = document.createElement("style");
tag.dataset.plugin = "@nex/pet-dock";
tag.dataset.pluginCss = cssId;
tag.textContent = css;
document.head.appendChild(tag);
}
var css = `
.pet-float {
position: fixed;
top: 50%;
right: 14px;
transform: translateY(-50%);
z-index: 2147483000;
pointer-events: none;
font-family: var(--dsw-alias-font, system-ui, 'Segoe UI', sans-serif);
}
.pet-float * { box-sizing: border-box; }
.pet-outer {
pointer-events: auto;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
.pet-avatar {
width: 64px; height: 64px; border-radius: 50%; border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 32px;
background: var(--dsw-alias-bg-overlay, #fff);
box-shadow: 0 4px 18px rgba(0,0,0,.18), inset 0 0 0 1px var(--dsw-alias-border-l1, rgba(0,0,0,.08));
animation: pet-bob 3s ease-in-out infinite;
}
@keyframes pet-bob { 0%,100%{transform:translateY(0);} 50%{transform:translateY(-6px);} }
.pet-window {
width: 220px;
background: var(--dsw-alias-bg-layer-1, #fff);
border-radius: 20px;
box-shadow: 0 18px 50px rgba(0,0,0,.26), inset 0 0 0 1px var(--dsw-alias-border-l1, rgba(0,0,0,.06));
overflow: hidden;
transform-origin: bottom right;
animation: pet-grow .3s cubic-bezier(.34,1.45,.64,1) both;
}
@keyframes pet-grow { 0%{opacity:0;transform:scale(.7) translateX(12px);} 100%{opacity:1;transform:scale(1) translateX(0);} }
.pet-head { display:flex; align-items:center; justify-content:space-between; padding:10px 12px 4px; }
.pet-name { font-weight:700; font-size:14px; color:var(--dsw-alias-label-primary,#222); }
.pet-name small { font-weight:500; color:var(--dsw-alias-label-secondary,#888); margin-left:6px; }
.pet-stage { height: 176px; display:flex; align-items:center; justify-content:space-between; padding:0 6px; position:relative; }
.pet-stage .stage-ground { position:absolute; bottom:12px; left:24%; right:24%; height:9px; border-radius:50%; background:radial-gradient(ellipse at center, rgba(0,0,0,.10), transparent 70%); }
.pet-stage .figure-wrap { position:absolute; left:0; right:0; top:0; bottom:0; display:flex; align-items:flex-end; justify-content:center; padding-bottom:18px; cursor:pointer; }
.pet-stage .figure-wrap:hover .pet-figure { transform: translateY(-6px) scale(1.06); }
.pet-stage .figure-wrap .tap-tip { position:absolute; top:4px; left:0; right:0; text-align:center; font-size:10px; color:var(--dsw-alias-label-secondary,#888); opacity:.75; pointer-events:none; }
.pet-arrow {
position:relative; z-index:2; width:30px; height:30px; border-radius:50%;
border:1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
background:var(--dsw-alias-bg-overlay,#fff); color:var(--dsw-alias-label-secondary,#888);
font-size:16px; line-height:1; cursor:pointer; display:flex; align-items:center; justify-content:center;
}
.pet-arrow:hover { background:var(--dsw-alias-bg-layer-2,#f3f4f8); color:var(--dsw-alias-brand-primary,#6c5ce7); transform:scale(1.1); }
.pet-status { text-align:center; font-size:11px; color:var(--dsw-alias-label-secondary,#888); padding:0 8px 10px; }
.pet-hint { text-align:center; font-size:10px; color:var(--dsw-alias-label-secondary,#888); padding:0 8px 8px; opacity:.7; }
.pet-figure { position:relative; transition:transform .25s ease; animation:body-bob 2.6s ease-in-out infinite; filter:drop-shadow(0 8px 10px rgba(0,0,0,.14)); }
@keyframes body-bob { 0%,100%{transform:translateY(0);} 50%{transform:translateY(-7px);} }
.p-eye { position:absolute; width:11px; height:11px; background:#222; border-radius:50%; animation:blink 4.2s steps(1) infinite; }
.p-eye::after { content:''; position:absolute; left:2px; top:1px; width:4px; height:4px; background:#fff; border-radius:50%; }
.p-eye.p-eyeB { background:#1e7a4d; }
@keyframes blink { 0%,88%,100%{transform:scaleY(1);} 92%,96%{transform:scaleY(.1);} }
@keyframes tail-wag { 0%,100%{transform:rotate(0);} 50%{transform:rotate(-16deg);} }
@keyframes dog-wag { 0%,100%{transform:rotate(-12deg);} 50%{transform:rotate(16deg);} }
@keyframes ear-flop { 0%,100%{transform:rotate(8deg);} 50%{transform:rotate(3deg);} }
@keyframes ear-flop-r { 0%,100%{transform:rotate(-8deg);} 50%{transform:rotate(-3deg);} }
.cow { position:relative; width:118px; height:122px; transform:scale(1.02); }
.cow-head { position:absolute; left:14px; top:14px; width:90px; height:82px; background:#fdfdfb; border-radius:46% 46% 40% 40%; box-shadow:inset 0 -10px 18px rgba(0,0,0,.05); }
.cow-patch { position:absolute; background:#3a3a42; border-radius:50%; }
.cow-patch.p1 { left:24px; top:5px; width:34px; height:22px; transform:rotate(12deg); }
.cow-patch.p2 { left:6px; top:34px; width:24px; height:17px; transform:rotate(-18deg); }
.cow-patch.p3 { right:12px; top:10px; width:17px; height:21px; transform:rotate(24deg); }
.cow-patch.p4 { right:2px; top:46px; width:20px; height:14px; background:#cfcfe0; transform:rotate(10deg); }
.cow-patch.p5 { left:9px; bottom:4px; width:17px; height:22px; background:#cfcfe0; transform:rotate(-22deg); }
.cow-ear { position:absolute; top:18px; width:24px; height:38px; background:#f0ede4; border-radius:50%; }
.cow-ear.l { left:1px; transform:rotate(-18deg); }
.cow-ear.r { right:1px; transform:rotate(18deg); }
.cow-ear::after { content:''; position:absolute; inset:6px 5px; background:#e8b7b0; border-radius:50%; }
.cow-horn { position:absolute; top:3px; width:0; height:0; border-left:9px solid transparent; border-right:9px solid transparent; border-bottom:19px solid #e4b983; }
.cow-horn.l { left:22px; transform:rotate(-24deg); }
.cow-horn.r { right:22px; transform:rotate(24deg); }
.cow-eye.l { left:20px; top:40px; z-index:2; }
.cow-eye.r { right:16px; top:42px; z-index:2; }
.cow-muzzle { position:absolute; left:25px; bottom:7px; width:40px; height:28px; background:#f2b6ad; border-radius:50%; }
.cow-muzzle::before, .cow-muzzle::after { content:''; position:absolute; top:8px; width:7px; height:9px; background:#d9837a; border-radius:50%; }
.cow-muzzle::before { left:8px; } .cow-muzzle::after { right:8px; }
.cow-body { position:absolute; left:14px; bottom:6px; width:90px; height:42px; background:#fdfdfb; border-radius:50% 50% 44% 44%; }
.cow-spot { position:absolute; background:#3a3a42; border-radius:50%; }
.cow-spot.s1 { left:2px; top:3px; width:21px; height:15px; transform:rotate(-14deg); }
.cow-spot.s2 { left:26px; top:2px; width:24px; height:15px; transform:rotate(8deg); }
.cow-spot.s3 { right:3px; top:8px; width:16px; height:15px; transform:rotate(-24deg); }
.cow-spot.s4 { left:11px; bottom:1px; width:15px; height:17px; background:#cfcfe0; transform:rotate(18deg); }
.cow-spot.s5 { right:13px; bottom:1px; width:17px; height:15px; background:#cfcfe0; transform:rotate(-8deg); }
.cow-leg { position:absolute; bottom:-8px; width:13px; height:21px; background:#efe7da; border-radius:4px; }
.cow-leg.l1 { left:16px; } .cow-leg.l2 { left:42px; }
.cow-leg.r1 { left:68px; } .cow-leg.r2 { left:92px; }
.cow-tail { position:absolute; right:2px; bottom:10px; width:34px; height:9px; border-radius:8px; background:#3a3a42; transform-origin:left center; animation:tail-wag 1.8s ease-in-out infinite; }
.cat { position:relative; width:112px; height:122px; transform:scale(1.04); }
.cat-head { position:absolute; left:16px; top:14px; width:80px; height:78px; background:linear-gradient(160deg,#ffb34d,#f79a35); border-radius:48% 48% 44% 44%; }
.cat-ear { position:absolute; top:0; width:0; height:0; border-left:15px solid transparent; border-right:15px solid transparent; border-bottom:28px solid #f79a35; }
.cat-ear.l { left:20px; transform:rotate(-14deg); }
.cat-ear.r { right:20px; transform:rotate(14deg); }
.cat-ear::after { content:''; position:absolute; left:-9px; top:11px; width:0; height:0; border-left:9px solid transparent; border-right:9px solid transparent; border-bottom:17px solid #ffc9d4; }
.cat-stripe { position:absolute; background:#e07f1f; border-radius:8px; }
.cat-stripe.s1 { left:24px; top:12px; width:7px; height:17px; transform:rotate(8deg); }
.cat-stripe.s2 { left:36px; top:9px; width:7px; height:19px; }
.cat-stripe.s3 { right:24px; top:12px; width:7px; height:17px; transform:rotate(-8deg); }
.cat-eye.l { left:22px; top:40px; background:#1e7a4d; }
.cat-eye.r { right:22px; top:40px; background:#1e7a4d; }
.cat-muzzle { position:absolute; left:29px; top:58px; width:22px; height:18px; background:#ffe9c9; border-radius:50%; }
.cat-nose { position:absolute; left:40px; top:52px; width:11px; height:7px; background:#f176a8; border-radius:50%; }
.cat-whisker { position:absolute; top:60px; width:20px; height:2px; background:rgba(0,0,0,.35); border-radius:2px; }
.cat-whisker.w1 { left:1px; transform:rotate(10deg); }
.cat-whisker.w2 { left:12px; top:68px; transform:rotate(-6deg); }
.cat-whisker.w3 { right:1px; transform:rotate(-10deg); }
.cat-whisker.w4 { right:12px; top:68px; transform:rotate(6deg); }
.cat-body { position:absolute; left:22px; bottom:7px; width:68px; height:42px; background:linear-gradient(160deg,#ffb34d,#f79a35); border-radius:48% 48% 42% 42%; }
.cat-belly { position:absolute; left:33px; bottom:7px; width:47px; height:36px; background:#fff3df; border-radius:48% 48% 42% 42%; }
.cat-paw { position:absolute; bottom:-5px; width:16px; height:13px; background:#f79a35; border-radius:50%; }
.cat-paw.p1 { left:27px; } .cat-paw.p2 { left:69px; }
.cat-tail { position:absolute; right:4px; bottom:8px; width:30px; height:11px; background:linear-gradient(180deg,#ffb34d 0%,#d9782b 100%); border-radius:8px 6px 6px 8px; transform-origin:left bottom; transform:rotate(-14deg); animation:tail-wag 2.2s ease-in-out infinite; }
.dog { position:relative; width:112px; height:124px; transform:scale(1.04); }
.dog-head { position:absolute; left:16px; top:16px; width:80px; height:76px; background:linear-gradient(160deg,#eebc83,#d99c5b); border-radius:48% 48% 44% 44%; }
.dog-ear { position:absolute; width:24px; height:36px; background:linear-gradient(180deg,#b9783d,#9c6230); border-radius:50% 50% 18px 18px; }
.dog-ear.l { left:6px; top:4px; transform-origin: top center; animation:ear-flop 2.6s ease-in-out infinite; }
.dog-ear.r { right:6px; top:4px; transform-origin: top center; animation:ear-flop-r 2.6s ease-in-out infinite; }
.dog-ear::after { content:''; position:absolute; left:7px; top:12px; width:10px; height:16px; background:#e8b6a0; border-radius:50%; }
.dog-snout { position:absolute; left:27px; top:56px; width:26px; height:18px; background:#f6e6cf; border-radius:50%; }
.dog-nose { position:absolute; left:39px; top:52px; width:13px; height:10px; background:#3a3028; border-radius:50%; }
.dog-mouth { position:absolute; left:37px; top:62px; width:16px; height:8px; border-bottom:2.5px solid #8a5a3a; border-radius:0 0 50% 50%; }
.dog-eye.l { left:30px; top:38px; }
.dog-eye.r { right:30px; top:38px; }
.dog-blush { position:absolute; top:50px; width:13px; height:8px; background:rgba(255,140,160,.5); border-radius:50%; }
.dog-blush.l { left:22px; } .dog-blush.r { right:22px; }
.dog-body { position:absolute; left:22px; bottom:7px; width:68px; height:44px; background:linear-gradient(160deg,#eebc83,#d99c5b); border-radius:48% 48% 42% 42%; }
.dog-belly { position:absolute; left:32px; bottom:7px; width:48px; height:36px; background:#f6e6cf; border-radius:48% 48% 42% 42%; }
.dog-paw { position:absolute; bottom:-5px; width:17px; height:14px; background:#d99c5b; border-radius:50%; }
.dog-paw.p1 { left:26px; } .dog-paw.p2 { left:69px; }
.dog-tail { position:absolute; right:3px; bottom:8px; width:30px; height:12px; background:linear-gradient(180deg,#eebc83 0%,#c08145 100%); border-radius:6px 7px 7px 6px; transform-origin:left bottom; transform:rotate(18deg); animation:dog-wag .8s ease-in-out infinite; }
.dog-tag { position:absolute; left:44px; bottom:16px; width:10px; height:10px; background:#ffd76a; border-radius:50%; box-shadow:0 0 0 2px #e6b84d; }
.pet-center { position:fixed; inset:0; z-index:2147483999; display:flex; align-items:center; justify-content:center; background:radial-gradient(120% 120% at 50% 40%, rgba(0,0,0,.10), transparent 70%); }
.pet-center .big-figure { cursor:pointer; filter:drop-shadow(0 16px 34px rgba(0,0,0,.28)); animation:center-in .55s cubic-bezier(.3,1.6,.5,1) both; }
.pet-center .big-figure.playing { animation:center-in .55s cubic-bezier(.3,1.6,.5,1) both, happy-dance 1.3s ease-in-out .4s infinite; }
.pet-center .big-figure.leaving { animation:center-out .55s cubic-bezier(.6,-0.1,.9,1) both; }
.pet-center .big-scale { transform:scale(calc(50vmin / 150px)); transform-origin:center; }
.pet-center .center-hint { position:absolute; bottom:14%; text-align:center; font-size:clamp(13px, 2.2vmin, 22px); font-weight:600; color:var(--dsw-alias-label-primary,#222); animation:hint-in .4s ease both; }
@keyframes center-in { 0%{opacity:0;transform:scale(.1);} 60%{transform:scale(1.15);} 100%{opacity:1;transform:scale(1);} }
@keyframes happy-dance { 0%,100%{transform:translateY(0) rotate(0) scale(1);} 25%{transform:translateY(-4%) rotate(-5deg) scale(1.02);} 50%{transform:translateY(0) rotate(0) scale(1);} 75%{transform:translateY(-4%) rotate(5deg) scale(1.02);} }
@keyframes center-out { 0%{opacity:1;transform:scale(1);} 100%{opacity:0;transform:scale(.06) translate(60vw, 0);} }
@keyframes hint-in { 0%{opacity:0;transform:translateY(8px);} 100%{opacity:1;transform:translateY(0);} }
/* ---- settings page ---- */
.pet-settings { display:flex; flex-direction:column; gap:12px; padding:4px 2px; }
.pet-settings-title { margin:0; font-size:16px; font-weight:600; color:var(--dsw-alias-label-primary,#222); }
.pet-settings-desc { margin:0; font-size:12px; color:var(--dsw-alias-label-secondary,#888); }
.pet-settings-row {
display:flex; align-items:center; gap:12px; padding:10px 12px;
border:1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
border-radius:12px; background:var(--dsw-alias-bg-layer-2,#f7f7fa);
}
.pet-settings-row.off { opacity:.55; }
.pet-settings-emoji { font-size:26px; flex:none; width:36px; text-align:center; }
.pet-settings-name {
flex:1; min-width:0; height:32px; border-radius:8px;
border:1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.14));
background:var(--dsw-alias-bg-overlay,#fff); color:var(--dsw-alias-label-primary,#222);
padding:0 10px; font-size:13px; outline:none;
}
.pet-settings-name:focus { border-color:var(--dsw-alias-brand-primary,#6c5ce7); }
.pet-settings-name:disabled { background:var(--dsw-alias-bg-layer-2,#f0f0f4); color:var(--dsw-alias-label-secondary,#999); }
.pet-settings-switch { display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; color:var(--dsw-alias-label-secondary,#888); white-space:nowrap; }
.pet-settings-switch input { width:15px; height:15px; accent-color:var(--dsw-alias-brand-primary,#6c5ce7); cursor:pointer; }
`;
// ---- data ---------------------------------------------------------------
var STORAGE_KEY = 'pet-dock:config';
var DEFAULT_PETS = [
{ kind: 'cow', name: '哞哞', emoji: '🐮', sub: '奶牛' },
{ kind: 'cat', name: '喵喵', emoji: '🐱', sub: '小猫' },
{ kind: 'dog', name: '汪汪', emoji: '🐶', sub: '小狗' },
];
function defaultConfig() {
var cfg = { enabled: {}, names: {} };
DEFAULT_PETS.forEach(function (p) { cfg.enabled[p.kind] = true; cfg.names[p.kind] = p.name; });
return cfg;
}
function loadConfig() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
var parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') return parsed;
}
} catch (e) {}
return defaultConfig();
}
function saveConfig(cfg) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); } catch (e) {}
}
function activePets() {
var cfg = loadConfig();
return DEFAULT_PETS.filter(function (p) { return cfg.enabled && cfg.enabled[p.kind] !== false; })
.map(function (p) {
var name = (cfg.names && cfg.names[p.kind]) || p.name;
return { kind: p.kind, name: name, emoji: p.emoji, sub: p.sub };
});
}
// ---- shared timing helper (browser-native; avoids service timing issues) ---
function later(fn, ms) {
var id = setTimeout(fn, ms);
return function () { clearTimeout(id); };
}
function PetFigure(_a) {
var pet = _a.pet;
var el = react.createElement;
if (pet.kind === 'cow') {
return el('div', { className: 'cow' },
el('div', { className: 'cow-tail' }),
el('div', { className: 'cow-body' },
el('div', { className: 'cow-spot s1' }),
el('div', { className: 'cow-spot s2' }),
el('div', { className: 'cow-spot s3' }),
el('div', { className: 'cow-spot s4' }),
el('div', { className: 'cow-spot s5' })),
el('div', { className: 'cow-leg l1' }),
el('div', { className: 'cow-leg l2' }),
el('div', { className: 'cow-leg r1' }),
el('div', { className: 'cow-leg r2' }),
el('div', { className: 'cow-horn l' }),
el('div', { className: 'cow-horn r' }),
el('div', { className: 'cow-ear l' }),
el('div', { className: 'cow-ear r' }),
el('div', { className: 'cow-head' },
el('div', { className: 'cow-patch p1' }),
el('div', { className: 'cow-patch p2' }),
el('div', { className: 'cow-patch p3' }),
el('div', { className: 'cow-patch p4' }),
el('div', { className: 'cow-patch p5' }),
el('div', { className: 'cow-muzzle' }),
el('div', { className: 'p-eye cow-eye l' }),
el('div', { className: 'p-eye cow-eye r' })));
}
if (pet.kind === 'cat') {
return el('div', { className: 'cat' },
el('div', { className: 'cat-tail' }),
el('div', { className: 'cat-body' }),
el('div', { className: 'cat-belly' }),
el('div', { className: 'cat-paw p1' }),
el('div', { className: 'cat-paw p2' }),
el('div', { className: 'cat-ear l' }),
el('div', { className: 'cat-ear r' }),
el('div', { className: 'cat-head' },
el('div', { className: 'cat-stripe s1' }),
el('div', { className: 'cat-stripe s2' }),
el('div', { className: 'cat-stripe s3' }),
el('div', { className: 'p-eye cat-eye l' }),
el('div', { className: 'p-eye cat-eye r' }),
el('div', { className: 'cat-muzzle' }),
el('div', { className: 'cat-nose' }),
el('div', { className: 'cat-whisker w1' }),
el('div', { className: 'cat-whisker w2' }),
el('div', { className: 'cat-whisker w3' }),
el('div', { className: 'cat-whisker w4' })));
}
return el('div', { className: 'dog' },
el('div', { className: 'dog-tail' }),
el('div', { className: 'dog-body' }),
el('div', { className: 'dog-belly' }),
el('div', { className: 'dog-paw p1' }),
el('div', { className: 'dog-paw p2' }),
el('div', { className: 'dog-ear l' }),
el('div', { className: 'dog-ear r' }),
el('div', { className: 'dog-tag' }),
el('div', { className: 'dog-head' },
el('div', { className: 'p-eye dog-eye l' }),
el('div', { className: 'p-eye dog-eye r' }),
el('div', { className: 'dog-blush l' }),
el('div', { className: 'dog-blush r' }),
el('div', { className: 'dog-snout' }),
el('div', { className: 'dog-nose' }),
el('div', { className: 'dog-mouth' })));
}
function PetWindow(props) {
var el = react.createElement;
var pet = props.pet, onPrev = props.onPrev, onNext = props.onNext, onPlay = props.onPlay, disabled = props.disabled;
return el('div', { className: 'pet-window' },
el('div', { className: 'pet-head' },
el('div', { className: 'pet-name' },
pet.emoji + ' ' + pet.name,
el('small', null, pet.sub))),
el('div', { className: 'pet-stage' },
el('div', { className: 'stage-ground' }),
el('div', { className: 'figure-wrap', onClick: onPlay, title: '点击互动' },
el('div', { className: 'tap-tip' }, '点击我互动 ✨'),
el('div', { className: 'pet-figure' }, el(PetFigure, { pet: pet }))),
el('button', { className: 'pet-arrow', onClick: onPrev, title: '上一个', disabled: disabled }, '‹'),
el('button', { className: 'pet-arrow', onClick: onNext, title: '下一个', disabled: disabled }, '›')),
el('div', { className: 'pet-status' }, '正在努力陪着你工作…'),
el('div', { className: 'pet-hint' }, '点击宠物,放大到屏幕中央互动'));
}
function CenterReveal(props) {
var el = react.createElement;
var pet = props.pet, leaving = props.leaving, onExit = props.onExit;
return el('div', { className: 'pet-center', onClick: onExit },
el('div', {
className: 'big-figure' + (leaving ? ' leaving' : ' playing'),
onClick: onExit
},
el('div', { className: 'big-scale' }, el(PetFigure, { pet: pet }))),
el('div', { className: 'center-hint' },
leaving ? '回到窗口…' : pet.emoji + ' ' + pet.name + ' 来找你玩啦!点击返回'));
}
// ---- settings page ------------------------------------------------------
function SettingsView() {
var el = react.createElement;
var useState = react.useState;
var _a = useState(0), tick = _a[0], setTick = _a[1];
var useEffect = react.useEffect;
useEffect(function () {
function onChange() { setTick(function (t) { return t + 1; }); }
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('pet-dock:config', onChange);
return function () { window.removeEventListener('pet-dock:config', onChange); };
}
}, []);
var cfg = loadConfig();
return el('div', { className: 'pet-settings' },
el('h3', { className: 'pet-settings-title' }, 'PetDock 宠物'),
el('p', { className: 'pet-settings-desc' }, '勾选要启用的形象,并为每只宠物自定义名字。'),
DEFAULT_PETS.map(function (p) {
var enabled = cfg.enabled && cfg.enabled[p.kind] !== false;
var name = (cfg.names && cfg.names[p.kind]) || p.name;
return el('div', { className: 'pet-settings-row' + (enabled ? '' : ' off'), key: p.kind },
el('span', { className: 'pet-settings-emoji' }, p.emoji),
el('input', {
className: 'pet-settings-name',
value: name,
disabled: !enabled,
placeholder: p.name,
onChange: function (e) {
var next = loadConfig();
next.names = next.names || {};
next.names[p.kind] = e.target.value;
saveConfig(next);
notifyConfig();
}
}),
el('label', { className: 'pet-settings-switch' },
el('input', {
type: 'checkbox',
checked: enabled,
onChange: function (e) {
var next = loadConfig();
next.enabled = next.enabled || {};
next.enabled[p.kind] = !!e.target.checked;
saveConfig(next);
notifyConfig();
}
}),
el('span', null, enabled ? '已启用' : '已停用')));
}));
}
function notifyConfig() {
if (typeof window !== 'undefined' && window.dispatchEvent) {
try { window.dispatchEvent(new Event('pet-dock:config')); } catch (e) {}
}
}
function apply(ctx) {
claimStyles();
var slots = ctx.slots;
slots.inject('settings.section', function () {
return slots.register(
{ name: 'settings.section', id: 'pet-dock', order: 50, label: 'PetDock' },
SettingsView);
});
slots.inject('shell.overlay', function () {
return slots.register(
{ name: 'shell.overlay', id: 'pet-dock' },
function () {
var useState = react.useState;
var useEffect = react.useEffect;
var _a = useState(0), index = _a[0], setIndex = _a[1];
var _b = useState(false), open = _b[0], setOpen = _b[1];
var _c = useState(false), switching = _c[0], setSwitching = _c[1];
var _d = useState(false), playing = _d[0], setPlaying = _d[1];
var _e = useState(false), leaving = _e[0], setLeaving = _e[1];
var _f = useState(0), tick = _f[0], setTick = _f[1];
useEffect(function () {
function onChange() { setTick(function (t) { return t + 1; }); }
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('pet-dock:config', onChange);
return function () { window.removeEventListener('pet-dock:config', onChange); };
}
}, []);
var pets = activePets();
var safeIndex = pets.length === 0 ? 0 : (index >= pets.length ? 0 : index);
var pet = pets.length === 0 ? null : pets[safeIndex];
var single = pets.length <= 1;
var cycle = function (dir) {
if (playing || pets.length === 0) return;
setSwitching(true);
later(function () {
setIndex(function (i) {
if (pets.length === 0) return 0;
var base = i >= pets.length ? 0 : i;
return (base + dir + pets.length) % pets.length;
});
setSwitching(false);
setOpen(true);
}, 180);
};
var play = function () {
if (!pet || playing) return;
setPlaying(true);
setLeaving(false);
later(function () {
setLeaving(true);
later(function () {
setPlaying(false);
setLeaving(false);
setOpen(false);
}, 560);
}, 1900);
};
var exit = function () {
if (!playing) return;
setLeaving(true);
later(function () {
setPlaying(false);
setLeaving(false);
setOpen(false);
}, 560);
};
var el = react.createElement;
var dock = null;
if (pet) {
dock = el('div', {
className: 'pet-float',
onMouseEnter: function () { if (!playing) setOpen(true); },
onMouseLeave: function () { if (!playing) setOpen(false); }
},
el('div', { className: 'pet-outer' },
open
? el(PetWindow, { pet: pet, switching: switching, disabled: single, onPrev: function () { return cycle(-1); }, onNext: function () { return cycle(1); }, onPlay: play })
: el('button', { className: 'pet-avatar', title: pet.name }, pet.emoji)));
}
var center = playing && pet
? el(CenterReveal, { pet: pet, leaving: leaving, onExit: exit })
: null;
return el(react.Fragment, null, dock, center);
});
});
}
var inject = ["slots"];
module.exports = { apply: apply, inject: inject };
return module.exports;
}
});

View File

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

View File

@ -1,19 +0,0 @@
{
"name": "@nex/pet-dock",
"version": "0.1.0",
"description": "Digital pets dock: cow, cat & dog pets at the right-middle edge with click-to-center interaction.",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.js",
"./package.json": "./package.json"
},
"dsh": {
"client": {
"platform": "web",
"immediately": true
}
},
"license": "MIT"
}

View File

@ -33,11 +33,10 @@ BACKEND_PORT=8000
FRONTEND_PORT=8080
# ==================== 前端配置 ====================
# API 基础 URL(根据实际部署方式选择)
# 方式1(推荐):使用 Nginx 反向代理,前后端同域名同端口
VITE_API_BASE_URL=/api
# 方式2:直接访问后端端口(需开放后端端口,可能有跨域问题)
# VITE_API_BASE_URL=http://yourdomain.com:8001
# 前端始终请求同源相对路径 /api/v1:
# - 开发环境由 vite 的 server.proxy 转发到后端
# - Docker 部署由前端容器内的 Nginx 反代(见 frontend/nginx.conf,上游为 compose 服务 backend)
# 因此无需配置前端侧的 API 地址;如需跨域直连后端,请自行修改 frontend/nginx.conf 或部署自己的网关。
# ==================== 存储配置 ====================
# 文件存储路径(宿主机路径,用于存储项目文档和上传文件)
@ -47,8 +46,12 @@ STORAGE_PATH=./storage
# 初始管理员账号信息
ADMIN_USERNAME=admin
ADMIN_PASSWORD=Admin@123456
ADMIN_EMAIL=admin@unisspace.com
# 首次初始化时创建的管理员邮箱(占位值,部署后请在「个人设置」中改为真实邮箱)
ADMIN_EMAIL=admin@example.com
ADMIN_NICKNAME=系统管理员
# 管理员在系统里新建用户时的初始密码(务必改为随机值;部署后立即修改管理员密码)
DEFAULT_USER_PASSWORD=User@123456
# 仅当 LLM/Embedding 服务使用自签名证书时开启(生产环境保持 false)
DISABLE_SSL_VERIFY=false

5
.gitignore vendored
View File

@ -13,6 +13,7 @@ Thumbs.db
# Project storage (user uploaded files)
storage/
backup/
backups/
# Documentation files (可能是临时的)
*.md.backup
@ -32,8 +33,12 @@ logs/
*.tmp
*.temp
# 本地运行产物(scripts/start.sh 生成的 pid / 日志)
.run/
# Local models
backend/models
# AI
.gemini-clipboard/
.claude/

View File

@ -1,158 +0,0 @@
# 部署配置更新日志
> ⚠️ 版本对齐:git 发布线当前为 v0.9.9(无 v1.0.x tag),下列 v1.0.1 为早期文案占位。详见 SDD `docs/sdd/releases/`。
## v1.0.1 (2024-12-23,遗留占位)
### 🔧 配置变更
#### 1. 前端端口调整
- **变更**: 前端默认端口从 `80` 改为 `8080`
- **原因**: 避免与常见服务冲突,提高兼容性
- **影响**:
- 访问地址变更为: `http://localhost:8080`
- 可在 `.env` 中配置 `FRONTEND_PORT` 自定义端口
#### 2. 存储目录映射优化
- **变更**: Storage 目录从 Docker Volume 改为宿主机目录映射
- **配置项**: 新增环境变量 `STORAGE_PATH`
- 默认值: `./storage`
- 支持相对路径和绝对路径
- 示例:
```bash
STORAGE_PATH=./storage # 相对路径
STORAGE_PATH=/data/nex-docus-data # 绝对路径
```
- **优势**:
- ✅ 便于直接访问和管理文件
- ✅ 方便备份和迁移
- ✅ 支持挂载到独立磁盘或网络存储
- ✅ 数据独立于容器生命周期
### 📝 配置文件更新
已更新以下文件:
- `.env.example` - 添加 `STORAGE_PATH` 配置,修改 `FRONTEND_PORT` 默认值
- `docker-compose.yml` - 修改 storage 为宿主机目录映射
- `deploy.sh` - 更新访问信息显示
- `DEPLOY.md` - 更新部署文档
### 🔄 迁移指南
如果您已经部署了旧版本,需要进行以下操作:
#### 方案 1: 保留现有数据(推荐)
```bash
# 1. 停止服务
./deploy.sh stop
# 2. 备份现有数据
docker run --rm -v nex-docus_storage_data:/from -v $(pwd)/storage:/to alpine sh -c "cd /from && cp -r . /to"
# 3. 更新配置文件
cp .env.example .env
vim .env # 配置 STORAGE_PATH=./storage
# 4. 重新启动
./deploy.sh start
# 5. 删除旧的 volume(可选)
docker volume rm nex-docus_storage_data
```
#### 方案 2: 全新部署
```bash
# 1. 备份数据库
./deploy.sh backup
# 2. 完全卸载
./deploy.sh uninstall
# 3. 重新初始化
./deploy.sh init
# 4. 恢复数据库(如需要)
./deploy.sh restore <backup_file>
```
### 📊 配置示例
#### 开发环境配置
```bash
FRONTEND_PORT=8080
BACKEND_PORT=8000
STORAGE_PATH=./storage
DEBUG=true
```
#### 生产环境配置
```bash
FRONTEND_PORT=80
BACKEND_PORT=8000
STORAGE_PATH=/data/nex-docus-storage
DEBUG=false
```
#### 多实例部署配置
```bash
# 实例 1
FRONTEND_PORT=8081
BACKEND_PORT=8001
STORAGE_PATH=/data/instance1/storage
# 实例 2
FRONTEND_PORT=8082
BACKEND_PORT=8002
STORAGE_PATH=/data/instance2/storage
```
### ⚙️ 存储路径说明
`STORAGE_PATH` 目录结构:
```
storage/
├── projects/ # 项目文档存储
│ ├── <uuid1>/ # 项目 1
│ │ ├── README.md
│ │ ├── docs/
│ │ └── _assets/ # 项目资源
│ └── <uuid2>/ # 项目 2
└── temp/ # 临时文件
```
### 🔒 安全建议
1. **权限设置**
```bash
# 设置适当的目录权限
chmod 755 storage
chown -R 1000:1000 storage # Docker 容器内用户
```
2. **备份策略**
```bash
# 定时备份 storage 目录
tar -czf storage_backup_$(date +%Y%m%d).tar.gz storage/
```
3. **网络存储**
```bash
# 挂载 NFS
mount -t nfs server:/share /data/nex-docus-storage
# 配置 .env
STORAGE_PATH=/data/nex-docus-storage
```
### 📞 支持
如有问题,请查看:
- [部署文档](DEPLOY.md)
- [项目文档](README_DOCKER.md)
- 或提交 Issue
---
**更新时间**: 2024-12-23

View File

@ -1,341 +0,0 @@
# NEX Docus 数据库设计文档
## 数据库连接信息
- **数据库类型**: MySQL 5.7.5+
- **连接地址**: 10.100.51.51:3306
- **数据库名**: nex_docus
- **字符集**: utf8mb4
- **排序规则**: utf8mb4_unicode_ci
## Redis 缓存
- **连接地址**: 10.100.51.51:6379
- **用途**: Session 存储、Token 黑名单、文件上传临时缓存
---
## 1. 用户认证相关表
### 1.1 用户表 (`users`)
存储系统用户基本信息。
```sql
CREATE TABLE `users` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID',
`username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名(登录账号)',
`password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希(bcrypt)',
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称(显示名称)',
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL',
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
`is_superuser` TINYINT DEFAULT 0 COMMENT '是否超级管理员:0-否 1-是',
`last_login_at` DATETIME DEFAULT NULL COMMENT '最后登录时间',
`last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_username` (`username`),
INDEX `idx_email` (`email`),
INDEX `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
```
### 1.2 角色表 (`roles`)
定义系统角色。
```sql
CREATE TABLE `roles` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '角色ID',
`role_name` VARCHAR(50) NOT NULL UNIQUE COMMENT '角色名称',
`role_code` VARCHAR(50) NOT NULL UNIQUE COMMENT '角色编码(如:admin, editor, viewer)',
`description` VARCHAR(255) DEFAULT NULL COMMENT '角色描述',
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
`is_system` TINYINT DEFAULT 0 COMMENT '是否系统角色:0-否 1-是(系统角色不可删除)',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_role_code` (`role_code`),
INDEX `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
```
**预置角色数据**:
```sql
INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES
('超级管理员', 'super_admin', '拥有系统所有权限', 1),
('项目管理员', 'project_admin', '可以创建和管理项目', 1),
('普通用户', 'user', '可以查看和编辑被授权的项目', 1),
('访客', 'guest', '只读权限', 1);
```
### 1.3 用户角色关联表 (`user_roles`)
用户与角色多对多关系。
```sql
CREATE TABLE `user_roles` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`role_id` BIGINT NOT NULL COMMENT '角色ID',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY `uk_user_role` (`user_id`, `role_id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_role_id` (`role_id`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';
```
---
## 2. 权限与菜单管理
### 2.1 系统菜单表 (`system_menus`)
定义系统功能菜单和权限点。
```sql
CREATE TABLE `system_menus` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '菜单ID',
`parent_id` BIGINT DEFAULT 0 COMMENT '父菜单ID(0表示根菜单)',
`menu_name` VARCHAR(50) NOT NULL COMMENT '菜单名称',
`menu_code` VARCHAR(50) NOT NULL UNIQUE COMMENT '菜单编码(权限标识)',
`menu_type` TINYINT NOT NULL COMMENT '菜单类型:1-目录 2-菜单 3-按钮/权限点',
`path` VARCHAR(255) DEFAULT NULL COMMENT '路由路径',
`component` VARCHAR(255) DEFAULT NULL COMMENT '组件路径',
`icon` VARCHAR(100) DEFAULT NULL COMMENT '图标',
`sort_order` INT DEFAULT 0 COMMENT '排序号',
`visible` TINYINT DEFAULT 1 COMMENT '是否可见:0-隐藏 1-显示',
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
`permission` VARCHAR(100) DEFAULT NULL COMMENT '权限字符串(如:project:create)',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_parent_id` (`parent_id`),
INDEX `idx_menu_code` (`menu_code`),
INDEX `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统菜单表';
```
**预置菜单数据**:
```sql
INSERT INTO `system_menus` (`id`, `parent_id`, `menu_name`, `menu_code`, `menu_type`, `path`, `icon`, `sort_order`, `permission`) VALUES
(1, 0, '项目管理', 'project', 1, '/projects', 'FolderOutlined', 1, NULL),
(2, 1, '我的项目', 'my_projects', 2, '/projects/my', NULL, 1, 'project:view'),
(3, 1, '创建项目', 'create_project', 3, NULL, NULL, 2, 'project:create'),
(4, 1, '编辑项目', 'edit_project', 3, NULL, NULL, 3, 'project:edit'),
(5, 1, '删除项目', 'delete_project', 3, NULL, NULL, 4, 'project:delete'),
(10, 0, '文档管理', 'document', 1, '/documents', 'FileTextOutlined', 2, NULL),
(11, 10, '查看文档', 'view_document', 3, NULL, NULL, 1, 'document:view'),
(12, 10, '编辑文档', 'edit_document', 3, NULL, NULL, 2, 'document:edit'),
(13, 10, '删除文档', 'delete_document', 3, NULL, NULL, 3, 'document:delete'),
(20, 0, '系统管理', 'system', 1, '/system', 'SettingOutlined', 3, NULL),
(21, 20, '用户管理', 'user_manage', 2, '/system/users', NULL, 1, 'system:user:view'),
(22, 20, '角色管理', 'role_manage', 2, '/system/roles', NULL, 2, 'system:role:view');
```
### 2.2 角色菜单授权表 (`role_menus`)
角色与菜单权限的多对多关系。
```sql
CREATE TABLE `role_menus` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID',
`role_id` BIGINT NOT NULL COMMENT '角色ID',
`menu_id` BIGINT NOT NULL COMMENT '菜单ID',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`),
INDEX `idx_role_id` (`role_id`),
INDEX `idx_menu_id` (`menu_id`),
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`menu_id`) REFERENCES `system_menus`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单授权表';
```
---
## 3. 项目与文档管理
### 3.1 项目表 (`projects`)
存储项目基本信息。
```sql
CREATE TABLE `projects` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '项目ID',
`name` VARCHAR(100) NOT NULL COMMENT '项目名称',
`description` VARCHAR(500) DEFAULT NULL COMMENT '项目描述',
`storage_key` CHAR(36) NOT NULL COMMENT '磁盘存储UUID(物理文件夹名)',
`owner_id` BIGINT NOT NULL COMMENT '项目所有者ID',
`is_public` TINYINT DEFAULT 0 COMMENT '是否公开:0-私有 1-公开',
`is_template` TINYINT DEFAULT 0 COMMENT '是否模板项目:0-否 1-是',
`status` TINYINT DEFAULT 1 COMMENT '状态:0-归档 1-活跃',
`cover_image` VARCHAR(255) DEFAULT NULL COMMENT '封面图',
`sort_order` INT DEFAULT 0 COMMENT '排序号',
`visit_count` INT DEFAULT 0 COMMENT '访问次数',
`git_repo_url` VARCHAR(255) DEFAULT NULL COMMENT 'Git仓库地址',
`git_branch` VARCHAR(50) DEFAULT 'main' COMMENT 'Git分支',
`git_username` VARCHAR(100) DEFAULT NULL COMMENT 'Git用户名',
`git_token` VARCHAR(255) DEFAULT NULL COMMENT 'Git访问令牌/密码',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY `uk_storage_key` (`storage_key`),
INDEX `idx_owner_id` (`owner_id`),
INDEX `idx_name` (`name`),
INDEX `idx_status` (`status`),
INDEX `idx_created_at` (`created_at`),
FOREIGN KEY (`owner_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目表';
```
### 3.2 项目成员表 (`project_members`)
项目协作成员管理。
```sql
CREATE TABLE `project_members` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '成员ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`role` ENUM('admin', 'editor', 'viewer') DEFAULT 'viewer' COMMENT '项目角色:admin-管理员 editor-编辑者 viewer-查看者',
`invited_by` BIGINT DEFAULT NULL COMMENT '邀请人ID',
`joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间',
UNIQUE KEY `uk_project_user` (`project_id`, `user_id`),
INDEX `idx_project_id` (`project_id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_role` (`role`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`invited_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目成员表';
```
### 3.3 文档元数据表 (`document_meta`)
可选表,用于存储文档的额外元数据(标签、评论数等)。
```sql
CREATE TABLE `document_meta` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '元数据ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径',
`title` VARCHAR(200) DEFAULT NULL COMMENT '文档标题',
`tags` VARCHAR(500) DEFAULT NULL COMMENT '标签(JSON数组)',
`author_id` BIGINT DEFAULT NULL COMMENT '作者ID',
`word_count` INT DEFAULT 0 COMMENT '字数统计',
`view_count` INT DEFAULT 0 COMMENT '浏览次数',
`last_editor_id` BIGINT DEFAULT NULL COMMENT '最后编辑者ID',
`last_edited_at` DATETIME DEFAULT NULL COMMENT '最后编辑时间',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY `uk_project_path` (`project_id`, `file_path`),
INDEX `idx_project_id` (`project_id`),
INDEX `idx_author_id` (`author_id`),
FULLTEXT KEY `ft_title_tags` (`title`, `tags`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON DELETE SET NULL,
FOREIGN KEY (`last_editor_id`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文档元数据表';
```
---
## 4. 操作日志与审计
### 4.1 操作日志表 (`operation_logs`)
记录关键操作日志。
```sql
CREATE TABLE `operation_logs` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID',
`user_id` BIGINT DEFAULT NULL COMMENT '操作用户ID',
`username` VARCHAR(50) DEFAULT NULL COMMENT '用户名(冗余字段)',
`operation_type` VARCHAR(50) NOT NULL COMMENT '操作类型(create, update, delete等)',
`resource_type` VARCHAR(50) NOT NULL COMMENT '资源类型(project, document, user等)',
`resource_id` BIGINT DEFAULT NULL COMMENT '资源ID',
`detail` TEXT DEFAULT NULL COMMENT '操作详情(JSON)',
`ip_address` VARCHAR(50) DEFAULT NULL COMMENT 'IP地址',
`user_agent` VARCHAR(500) DEFAULT NULL COMMENT '用户代理',
`status` TINYINT DEFAULT 1 COMMENT '状态:0-失败 1-成功',
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
INDEX `idx_user_id` (`user_id`),
INDEX `idx_resource` (`resource_type`, `resource_id`),
INDEX `idx_created_at` (`created_at`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';
```
---
## 5. 文件存储说明
### 5.1 物理存储结构
**根目录**: `/data/nex_docus_store/`
```
/data/nex_docus_store/
├── projects/
│ ├── <project_uuid>/ # 项目文件夹(UUID命名)
│ │ ├── README.md # 项目首页
│ │ ├── _assets/ # 资源文件夹(图片、附件)
│ │ │ ├── images/ # 图片
│ │ │ └── files/ # 附件
│ │ ├── <folder>/ # 用户创建的文件夹
│ │ │ └── *.md # Markdown文档
│ │ └── ...
└── temp/ # 临时文件(上传缓存)
```
### 5.2 存储规则
1. **项目隔离**: 每个项目使用独立的 UUID 文件夹
2. **路径映射**: `storage_key` 字段存储 UUID,数据库存储显示名称
3. **资源管理**: 图片和附件存储在 `_assets` 目录
4. **安全控制**: 所有文件访问必须经过权限验证
5. **备份策略**: 可直接备份 `/data/nex_docus_store/` 目录
---
## 6. 索引优化建议
1. **复合索引**:
- `projects`: (`owner_id`, `status`)
- `project_members`: (`project_id`, `role`)
- `document_meta`: (`project_id`, `last_edited_at`)
2. **覆盖索引**: 针对高频查询添加包含查询字段的复合索引
3. **分区表**: 当 `operation_logs` 数据量大时,可按月份分区
---
## 7. 数据库初始化脚本
创建完整的初始化 SQL 文件:`backend/scripts/init_database.sql`
执行顺序:
1. 创建数据库
2. 创建所有表
3. 插入初始角色数据
4. 插入初始菜单数据
5. 创建默认管理员用户
---
## 8. 性能优化建议
1. **连接池配置**: SQLAlchemy 配置合理的连接池大小
2. **查询优化**: 使用 `joinedload` 避免 N+1 查询
3. **缓存策略**:
- 用户信息缓存(5分钟)
- 菜单权限缓存(10分钟)
- 项目列表缓存(1分钟)
4. **读写分离**: 后续可配置主从数据库
---
**文档版本**: v1.0(与代码版本 v0.9.9 的 git 基线无直接对应,见 SDD releases/)
**最后更新**: 2023-12-20
**维护人**: Mula.liu

330
DEPLOY.md
View File

@ -1,330 +0,0 @@
# NEX Docus Docker 部署文档
完整的 Docker 容器化部署方案,支持一键部署、升级、备份等功能。
## 🚀 快速开始
### 1. 环境要求
- Docker 20.10+
- Docker Compose 2.0+
- 至少 2GB 可用内存
- 至少 10GB 可用磁盘空间
### 2. 首次部署
```bash
# 1. 克隆项目(如果还没有)
git clone <your-repo-url>
cd "NEX Docus"
# 2. 配置环境变量
cp .env.example .env
vim .env # 编辑配置文件
# 3. 初始化并启动
./deploy.sh init
```
### 3. 配置说明
编辑 `.env` 文件,修改以下关键配置:
```bash
# 数据库配置
DB_NAME=nex_docus
DB_USER=nexdocus
DB_PASSWORD=your_secure_password_here
# Redis 配置
REDIS_PASSWORD=your_redis_password_here
# JWT 密钥(必须修改!)
SECRET_KEY=your-secret-key-change-me-in-production
# 服务端口配置
FRONTEND_PORT=8080 # 前端访问端口
BACKEND_PORT=8000 # 后端 API 端口
# 存储路径配置(用于存储项目文档和上传文件)
STORAGE_PATH=./storage # 可修改为绝对路径,如 /data/nex-docus-storage
# 管理员账号
ADMIN_USERNAME=admin
ADMIN_PASSWORD=Your_Secure_Password_123
ADMIN_EMAIL=admin@yourdomain.com
# API 地址配置
# 推荐:使用 Nginx 反向代理(前后端同域名同端口,无跨域问题)
VITE_API_BASE_URL=/api
# 或:直接访问后端端口(需开放后端端口到外网)
# VITE_API_BASE_URL=http://yourdomain.com:8001
```
**⚠️ 重要提示:**
- `SECRET_KEY` 必须修改为随机字符串(可用 `openssl rand -hex 32` 生成)
- 生产环境必须修改所有默认密码
- `DEBUG` 设置为 `false`
- `STORAGE_PATH` 可配置为绝对路径,便于数据管理和备份
- **API 访问配置**:
- 使用 `VITE_API_BASE_URL=/api` 时,前端通过 Nginx 反向代理访问后端,无需开放后端端口
- 使用 `VITE_API_BASE_URL=http://IP:8001` 时,直接访问后端,需要开放 8001 端口
## 📋 部署脚本使用
### 基本命令
```bash
# 查看帮助
./deploy.sh help
# 初始化部署(首次部署)
./deploy.sh init
# 启动服务
./deploy.sh start
# 停止服务
./deploy.sh stop
# 重启服务
./deploy.sh restart
# 查看服务状态
./deploy.sh status
```
### 日志管理
```bash
# 查看所有服务日志
./deploy.sh logs
# 查看后端日志
./deploy.sh logs backend
# 查看前端日志
./deploy.sh logs frontend
# 查看数据库日志
./deploy.sh logs mysql
# 查看 Redis 日志
./deploy.sh logs redis
```
### 升级部署
```bash
# 升级到最新版本
./deploy.sh upgrade
```
升级流程:
1. 拉取最新代码
2. 停止服务
3. 构建新镜像
4. 更新数据库
5. 启动服务
6. 清理旧镜像
### 数据库管理
```bash
# 备份数据库
./deploy.sh backup
# 恢复数据库
./deploy.sh restore ./backups/nex_docus_20240101_120000.sql
```
### 卸载
```bash
# 完全卸载(删除所有容器、镜像和数据)
./deploy.sh uninstall
```
## 🏗️ 架构说明
### 服务组成
| 服务 | 端口 | 说明 |
|------|------|------|
| frontend | 8080 | 前端 Nginx 服务 |
| backend | 8000 | 后端 FastAPI 服务 |
| mysql | 3306 | MySQL 8.0 数据库 |
| redis | 6379 | Redis 缓存 |
### 目录结构
```
NEX Docus/
├── backend/ # 后端代码
│ ├── app/ # 应用代码
│ ├── scripts/ # 脚本文件
│ │ └── init_db.py # 数据库初始化
│ ├── Dockerfile # 后端镜像
│ └── requirements.txt # Python 依赖
├── frontend/ # 前端代码
│ ├── src/ # 源代码
│ ├── Dockerfile # 前端镜像
│ └── nginx.conf # Nginx 配置
├── docker-compose.yml # Docker 编排文件
├── .env.example # 环境变量示例
├── deploy.sh # 部署管理脚本
└── DEPLOY.md # 部署文档
```
### 数据持久化
所有重要数据都已持久化存储:
- `mysql_data`: MySQL 数据(Docker Volume)
- `redis_data`: Redis 数据(Docker Volume)
- `${STORAGE_PATH}`: 用户上传的文件和项目文档(宿主机目录映射)
- 默认路径: `./storage`
- 可在 `.env` 中配置 `STORAGE_PATH` 修改为其他路径
- 建议使用绝对路径,便于备份和迁移
**存储目录说明:**
- 该目录存储所有项目文档和上传的文件
- 支持独立备份和迁移
- 可配置到独立磁盘或网络存储
## 🔧 常见问题
### 1. 端口被占用
如果默认端口被占用,可以在 `.env` 中修改:
```bash
FRONTEND_PORT=8080
BACKEND_PORT=8001
MYSQL_PORT=3307
REDIS_PORT=6380
```
### 2. 数据库连接失败
检查 MySQL 容器状态:
```bash
docker logs nex-docus-mysql
```
确保数据库完全启动(约需 10-15 秒)
### 3. 前端无法访问后端 API
检查 `.env` 中的 `VITE_API_BASE_URL` 配置,确保与实际部署地址匹配。
### 4. 内存不足
如果服务器内存小于 2GB,可能导致 MySQL 无法启动。建议:
- 增加服务器内存
- 或使用外部 MySQL 服务
### 5. 镜像下载缓慢
已配置国内镜像加速:
- Docker 基础镜像使用华为云镜像
- Debian APT 包使用阿里云镜像源
- Python 使用清华源
- Node 使用淘宝镜像
**Docker 镜像源配置**(如需更换):
编辑 `/etc/docker/daemon.json`:
```json
{
"registry-mirrors": [
"https://mirror.ccs.tencentyun.com",
"https://docker.mirrors.ustc.edu.cn"
]
}
```
**说明**:
- `backend/Dockerfile` 已配置阿里云 Debian APT 源,系统依赖安装速度大幅提升
- 如构建仍然缓慢,可尝试清华源:`mirrors.tuna.tsinghua.edu.cn`
## 🔐 安全建议
1. **修改默认密码**
- 管理员账号密码
- 数据库密码
- Redis 密码
- JWT 密钥
2. **配置防火墙**
```bash
# 只开放必要端口
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
```
3. **使用 HTTPS**
- 建议配置 Nginx 反向代理
- 使用 Let's Encrypt 免费证书
4. **定期备份**
```bash
# 添加定时任务
crontab -e
# 每天凌晨 2 点备份
0 2 * * * cd /path/to/nex-docus && ./deploy.sh backup
```
5. **关闭调试模式**
```bash
DEBUG=false
```
## 📊 监控与维护
### 查看资源使用情况
```bash
# Docker 容器资源使用
docker stats
# 磁盘使用情况
df -h
# 清理 Docker 缓存
docker system prune -a
```
### 定期维护
```bash
# 每周检查日志大小
du -sh backend/logs
# 清理旧日志
find backend/logs -name "*.log" -mtime +30 -delete
```
## 🆘 技术支持
如遇问题,请检查:
1. 服务日志: `./deploy.sh logs <服务名>`
2. Docker 状态: `docker ps -a`
3. 系统资源: `top` 或 `htop`
## 📝 更新日志
> ⚠️ 版本对齐:git 发布线当前为 v0.9.9(无 v1.0.0 tag),下列 v1.0.0 为早期文案占位。详见 SDD `docs/sdd/releases/`。
### v1.0.0 (2024-12-20,遗留占位)
- ✨ 完整的 Docker 部署方案
- ✨ 一键初始化和升级
- ✨ 数据库备份恢复
- ✨ 国内镜像加速
- ✨ 完善的文档和脚本
---
**祝部署顺利!** 🎉

View File

@ -1,17 +0,0 @@
# 开发环境说明
## Mysql:
+ 连接字符串:10.100.51.51:3306
+ 数据库名:nex_docus
+ 字符集:utf8mb4
+ 排序规则:utf8mb4_unicode_ci
+ 用户名、密码: root | Unis@123
## Redis:
+ 连接字符串:10.100.51.51:6379
+ db:1
+ 密码: Unis@123
## 本地开发环境:
frontend: 前端
backend: 后端(已经创建虚拟环境venv)

View File

@ -1,230 +0,0 @@
# NEX Docus 快速启动指南
欢迎使用 NEX Docus!这是一个完整的快速启动指南,帮助你在 5 分钟内运行整个项目。
---
## 📋 前置要求
确保已安装以下软件:
- **Python**: 3.9.6+
- **Node.js**: 16+
- **MySQL**: 5.7.5+
- **Redis**: 最新稳定版
- **Git**: 最新版本
---
## 🚀 快速启动(3 步)
### Step 1: 初始化数据库
```bash
# 1. 连接到 MySQL
mysql -h10.100.51.51 -uroot -pUnis@321
# 2. 执行初始化脚本
source backend/scripts/init_database.sql
# 或使用命令行直接执行
mysql -h10.100.51.51 -uroot -pUnis@321 < backend/scripts/init_database.sql
```
### Step 2: 启动后端服务
```bash
# 1. 进入后端目录
cd backend
# 2. 激活虚拟环境
source venv/bin/activate # macOS/Linux
# 或
venv\Scripts\activate # Windows
# 3. 安装依赖
pip install -r requirements.txt
# 4. 启动服务
python main.py
```
后端服务将在 http://localhost:8000 启动
### Step 3: 启动前端服务
```bash
# 1. 打开新终端,进入前端目录
cd frontend
# 2. 安装依赖
npm install
# 或
pnpm install
# 3. 启动开发服务器
npm run dev
```
前端应用将在 http://localhost:5173 启动
---
## 🎉 开始使用
### 1. 登录系统
访问 http://localhost:5173,使用默认管理员账号登录:
- **用户名**: `admin`
- **密码**: `admin123`
### 2. 创建项目
- 点击「创建项目」按钮
- 填写项目名称和描述
- 提交创建
### 3. 编辑文档
- 点击项目卡片进入项目
- 在左侧目录树中选择文件
- 在右侧编辑器中编辑 Markdown
- 点击「保存」按钮保存更改
---
## 📚 目录结构
```
NEX Docus/
├── backend/ # 后端服务(FastAPI)
│ ├── app/
│ │ ├── api/ # API 路由
│ │ ├── core/ # 核心配置
│ │ ├── models/ # 数据库模型
│ │ ├── schemas/ # Pydantic Schemas
│ │ ├── services/ # 业务逻辑
│ │ └── middleware/ # 中间件
│ ├── scripts/ # 脚本文件
│ ├── main.py # 应用入口
│ └── requirements.txt # 依赖包
│
├── frontend/ # 前端应用(React + Vite)
│ ├── src/
│ │ ├── api/ # API 请求
│ │ ├── components/ # 通用组件
│ │ ├── pages/ # 页面组件
│ │ ├── stores/ # 状态管理
│ │ └── utils/ # 工具函数
│ ├── package.json
│ └── vite.config.js
│
├── DATABASE.md # 数据库设计文档
├── IMPLEMENTATION_PLAN.md # 实施计划
├── PROJECT.md # 项目技术方案
└── DEPLOYE.md # 部署配置
```
---
## 🔧 常见问题
### 1. 后端启动失败
**问题**: `ModuleNotFoundError: No module named 'xxx'`
**解决**:
```bash
cd backend
source venv/bin/activate
pip install -r requirements.txt
```
### 2. 数据库连接失败
**问题**: `Can't connect to MySQL server`
**解决**:
- 检查 `backend/.env` 中的数据库配置
- 确认 MySQL 服务已启动
- 测试数据库连接:
```bash
mysql -h10.100.51.51 -uroot -pUnis@321 -e "SELECT 1"
```
### 3. 前端请求 404
**问题**: API 请求返回 404
**解决**:
- 确认后端服务已启动
- 检查 `frontend/.env` 中的 API 地址配置
- 检查浏览器控制台的网络请求
### 4. 文件上传/保存失败
**问题**: 文件操作失败
**解决**:
- 确保文件存储目录存在并有写权限:
```bash
mkdir -p /data/nex_docus_store/{projects,temp}
chmod 755 /data/nex_docus_store
```
- 或修改 `backend/.env` 中的存储路径为当前用户有权限的目录
---
## 📖 API 文档
启动后端服务后,访问以下地址查看 API 文档:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
---
## 🔐 安全提醒
⚠️ **生产环境部署前请务必修改:**
1. 修改默认管理员密码
2. 修改 `backend/.env` 中的 `SECRET_KEY`
3. 配置 HTTPS
4. 限制 CORS 允许的域名
5. 配置防火墙规则
---
## 📞 技术支持
如果遇到问题,请查看:
1. **PROJECT.md** - 完整技术方案
2. **DATABASE.md** - 数据库设计文档
3. **IMPLEMENTATION_PLAN.md** - 实施计划
或联系技术负责人:Mula.liu
---
## ⚡️ 快速命令参考
```bash
# 后端
cd backend && source venv/bin/activate && python main.py
# 前端
cd frontend && npm run dev
# 数据库初始化
mysql -h10.100.51.51 -uroot -pUnis@321 < backend/scripts/init_database.sql
# 查看日志
tail -f backend/logs/app.log
```
---
**祝你使用愉快!🎊**

328
README.md
View File

@ -2,263 +2,177 @@
<div align="center">
一个轻量级、高性能的团队协作文档管理平台
轻量、可自托管的团队文档中心:文件系统存储内容,数据库管理权限,内置 AI 知识库问答。
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Python](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/)
[![React](https://img.shields.io/badge/react-18+-blue.svg)](https://reactjs.org/)
[![FastAPI](https://img.shields.io/badge/fastapi-0.109+-green.svg)](https://fastapi.tiangolo.com/)
v1.0.0 · FastAPI + React 18 + MySQL 8 + Redis
</div>
---
## ✨ 特性
## ✨ 核心特性
- 🚀 **高性能**: FastAPI 异步后端 + React 18 前端
- 📁 **文件存储**: 数据库管理权限 + 文件系统存储内容
- 🔐 **权限控制**: 完整的 RBAC 权限体系
- 📝 **Markdown 编辑**: 实时预览、图片上传
- 👥 **团队协作**: 项目成员管理、角色分配
- 🌲 **目录树**: 无限层级目录支持
- 💾 **数据安全**: 文件即真理、易于备份和迁移
- 📁 **文件即真理**:文档正文以 Markdown 文件形式存放在磁盘(`storage/projects/<uuid>/…`),数据库只保存权限与元数据,备份/迁移只需拷目录。
- 📝 **Markdown 编辑**:基于 ByteMD 的编辑器,支持 **编辑 / 分栏 / 预览** 三态切换,右侧悬浮目录(TOC)在纯编辑模式下同样可用;支持 GFM、代码高亮、Frontmatter、emoji、图片上传。
- 🌲 **无限层级目录树**:文件/文件夹创建、重命名、拖拽移动、排序。
- 👥 **团队协作**:项目成员与项目内角色(admin / editor / viewer),公开项目与「参与项目」列表。
- 🔎 **双引擎检索**:本地全文检索(Whoosh + jieba 中文分词)+ 向量检索(ZVec / 远程 Embedding)。
- 🤖 **AI 知识库问答**:基于项目文档的 RAG 对话,返回引用文件与命中片段,可中断、可回看思考过程与耗时。
- 🧩 **模型配置中心**:在系统管理里维护 Chat / Embedding 模型(OpenAI 兼容接口、本地 sentence-transformers),运行时热切换。
- 🔗 **分享与预览**:项目/单文件分享链接(可带访问密码)、Markdown / PDF / 图片预览,PDF 支持服务端导出。
- 🔐 **RBAC**:用户 / 角色 / 菜单与按钮级权限点,侧边栏按授权动态生成。
- 📄 **通知与审计**:站内通知中心、全量操作日志检索。
- 🔄 **Git 双向同步**:项目可绑定 Git 仓库,按目录同步文档。
- 🛰️ **MCP 接入**:为 MCP Bot 提供凭证与文档读写能力。
- 🌗 **明暗双主题**:全站统一设计令牌(`styles/design-tokens.css` + antd 主题),跟随系统或手动切换。
---
## 🏗️ 技术栈
## 🏗️ 技术架构
### 后端
| 组件 | 选型 |
| --- | --- |
| Web 框架 | FastAPI 0.109 + Uvicorn(全异步) |
| ORM | SQLAlchemy 2.0(asyncio + aiomysql) |
| 数据库 | MySQL 8.0(utf8mb4) |
| 缓存/队列 | Redis 7 |
| 认证 | JWT(python-jose)+ bcrypt 密码哈希 |
| 检索 | Whoosh3 + jieba(全文)、ZVec + sentence-transformers / 远程 Embedding(向量) |
| 文档处理 | markdown、weasyprint(PDF)、python-magic |
| 集成 | OpenAI 兼容 LLM 接口、系统 `git` 命令(subprocess 调用)、MCP SDK |
### 后端技术栈
- **框架**: FastAPI (Python 3.9+)
- **ORM**: SQLAlchemy 2.0 (异步)
- **数据库**: MySQL 5.7.5+
- **缓存**: Redis
- **认证**: JWT (PyJWT)
- **文件**: aiofiles (异步 I/O)
### 前端技术栈
- **框架**: React 18
- **构建工具**: Vite
- **UI 组件**: Ant Design 5
- **路由**: React Router v6
- **状态管理**: Zustand
- **Markdown**: @uiw/react-md-editor
- **样式**: Tailwind CSS
---
### 前端
| 组件 | 选型 |
| --- | --- |
| 框架 | React 18 + React Router v6 |
| 构建 | Vite 5(`manualChunks` 分包、全部路由 lazy 加载) |
| UI | Ant Design 5 + 自研设计令牌(无 Tailwind / postcss) |
| 状态 | Zustand + Axios 统一封装 |
| Markdown | ByteMD(`@bytemd/react` + gfm/highlight/frontmatter/breaks/gemoji)+ react-markdown 渲染 |
| 其它 | react-pdf / pdfjs-dist、react-virtuoso、antd-img-crop |
## 📦 项目结构
```
NEX Docus/
├── backend/ # FastAPI 后端服务
NexDocus/
├── backend/ # FastAPI 后端
│ ├── app/
│ │ ├── api/v1/ # API 路由(v1)
│ │ ├── core/ # 核心配置
│ │ ├── models/ # 数据库模型
│ │ ├── schemas/ # Pydantic Schemas
│ │ ├── services/ # 业务逻辑服务
│ │ └── middleware/ # 中间件
│ ├── scripts/ # 初始化脚本
│ │ ├── api/v1/ # API 路由(认证/项目/文件/检索/对话/分享/系统…)
│ │ ├── core/ # 配置、数据库、安全、依赖注入、幂等迁移
│ │ ├── models/ # SQLAlchemy 模型(18 张表)
│ │ ├── schemas/ # Pydantic Schema
│ │ ├── services/ # 业务逻辑(存储、检索、向量化、RAG、Git、导出…)
│ │ └── mcp/ # MCP Streamable HTTP 接入(凭证鉴权 + 工具注册)
│ ├── scripts/ # 数据库初始化脚本(随代码走,Docker 构建上下文需要)
│ ├── tests/ # pytest 用例
│ └── main.py # 应用入口
│
├── frontend/ # React 前端应用
│ ├── src/
│ │ ├── api/ # API 封装
│ │ ├── components/ # 通用组件
│ │ ├── pages/ # 页面组件
│ │ ├── stores/ # 状态管理
│ │ └── utils/ # 工具函数
│ └── package.json
├── frontend/ # React 前端
│ └── src/
│ ├── api/ # 接口封装
│ ├── components/ # 通用组件(Feedback 统一提示、MainLayout…)
│ ├── data/ # 静态配置数据
│ ├── pages/ # 页面(全部 lazy 加载)
│ ├── stores/ # Zustand
│ ├── styles/ # design-tokens.css 等全局样式
│ ├── theme/ # antd 主题(明/暗)
│ └── utils/
│
├── DATABASE.md # 数据库设计文档
├── PROJECT.md # 技术方案文档
├── QUICKSTART.md # 快速启动指南
└── IMPLEMENTATION_PLAN.md # 实施计划
├── scripts/ # 运维/开发脚本(start / stop / deploy)
├── docs/ # 全部文档(见 docs/README.md)
├── storage/ # 运行期文件存储(已 gitignore)
├── backup/ # 数据库备份产物(已 gitignore)
├── .run/ # 本地 pid / 日志(已 gitignore)
├── docker-compose.yml # 容器编排
└── .env.example # Docker 部署环境变量模板
```
---
## 🚀 快速开始
### 前置要求
### 环境要求
- Python **3.10+**(推荐 3.12)
- Node.js **18+**
- MySQL **8.0**、Redis **7**(已有实例亦可,Docker 部署会自动拉起)
- 启用 Git 同步时需要本机存在 `git` 可执行文件(后端镜像已内置)
- Python 3.9.6+
- Node.js 16+
- MySQL 5.7.5+
- Redis (最新版)
### 1. 初始化数据库
### 方式一:一键启动(本地开发,推荐)
```bash
mysql -h10.100.51.51 -uroot -pUnis@321 < backend/scripts/init_database.sql
./scripts/start.sh
```
### 2. 启动后端
首次运行会:创建 `backend/venv` → 生成 `backend/.env` 模板(**此时会停下**,请填好 MySQL/Redis 连接信息后重新执行)→ 安装前后端依赖 → 真实校验 MySQL/Redis 连通性 → 幂等初始化数据库 → 拉起后端与前端。
```bash
cd backend
source venv/bin/activate
pip install -r requirements.txt
python main.py
./scripts/start.sh --backend # 只启动后端
./scripts/start.sh --frontend # 只启动前端
./scripts/start.sh --install # 只准备环境,不启动服务
./scripts/start.sh --init-db # 强制重跑数据库初始化
./scripts/start.sh --port 8001 # 换端口(环境变量优先,不改 .env)
./scripts/start.sh --daemon # 启动后立即返回(后台常驻)
./scripts/stop.sh # 停止
```
后端将运行在 http://localhost:8000
启动完成后:前端 http://localhost:5173 · 后端 http://localhost:8000 · 接口文档 http://localhost:8000/docs
### 3. 启动前端
### 方式二:Docker Compose(服务器部署)
```bash
cd frontend
npm install
npm run dev
cp .env.example .env # 按注释修改密码/端口/存储路径
./scripts/deploy.sh init # 生成配置、构建镜像、初始化数据库
./scripts/deploy.sh start
./scripts/deploy.sh status
```
前端将运行在 http://localhost:5173
细节见 [docs/deploy/README.md](docs/deploy/README.md)。
### 4. 登录使用
### 默认账号
- **用户名**: `admin`
- **密码**: `admin123`
| 场景 | 用户名 | 密码 |
| --- | --- | --- |
| `scripts/start.sh` / `backend/scripts/init_db.py` | `admin` | `admin@123` |
| Docker 部署(`.env.example` 默认值) | `admin` | `Admin@123456` |
---
可用 `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL` / `ADMIN_NICKNAME` 覆盖;**首次登录后请立即修改密码**。
## 📖 核心功能
## 📖 文档索引
### 用户认证
- ✅ 用户注册/登录
- ✅ JWT Token 认证
- ✅ 密码加密存储
- ✅ 权限角色管理
| 文档 | 内容 |
| --- | --- |
| [docs/README.md](docs/README.md) | 文档地图(从这里开始) |
| [docs/quickstart.md](docs/quickstart.md) | 开发环境快速上手、常见启动问题 |
| [docs/deploy/README.md](docs/deploy/README.md) | Docker 部署、升级、备份与恢复 |
| [docs/database.md](docs/database.md) | 数据库 18 张表结构与初始化链路 |
| [docs/manual/user-guide.md](docs/manual/user-guide.md) | 面向使用者的功能手册 |
| [docs/sdd/](docs/sdd/) | 规格驱动开发文档:愿景、架构、ADR、DV 规格、发布记录 |
| [scripts/README.md](scripts/README.md) | 脚本清单与约定 |
### 项目管理
- ✅ 创建/编辑/删除项目
- ✅ 项目成员管理
- ✅ 访问权限控制
- ✅ 项目归档
## 🧪 开发与质量
### 文档编辑
- ✅ Markdown 实时预览
- ✅ 无限层级目录
- ✅ 文件/文件夹操作
- ✅ 图片/附件上传
- ✅ 自动保存
```bash
# 前端:静态检查与构建
cd frontend && npm run lint && npm run build
---
# 后端:pytest(用例较少,见发布报告 OI 清单)
cd backend && ./venv/bin/pip install -r requirements-dev.txt
cd backend && ./venv/bin/python -m pytest
```
## 🗂️ 数据库设计
- 前端统一使用 `@/` 别名指向 `frontend/src`;新页面必须在 `App.jsx` 用 `lazy()` 注册。
- 交互提示统一走 `components/Feedback` 的 `Toast`(`Toast.confirm` 替代 `Modal.confirm`)。
- 颜色/圆角/间距一律取 `styles/design-tokens.css` 与 antd token,禁止硬编码色值。
- 数据库结构变更:改 `app/models/`,并在 `app/core/migrations.py` 增加幂等补列逻辑;**不要**再往仓库里追加一次性 `.sql`。
### 核心表结构
## 🔒 安全要点
- `users` - 用户表
- `roles` - 角色表
- `user_roles` - 用户角色关联
- `system_menus` - 系统菜单
- `role_menus` - 角色菜单授权
- `projects` - 项目表
- `project_members` - 项目成员
- `document_meta` - 文档元数据(可选)
- `operation_logs` - 操作日志
详细设计请查看 [DATABASE.md](DATABASE.md)
---
## 🔒 安全特性
- ✅ **密码加密**: bcrypt 加密存储
- ✅ **JWT 认证**: 访问令牌机制
- ✅ **路径安全**: 防止路径穿越攻击
- ✅ **权限校验**: 基于角色的访问控制
- ✅ **SQL 注入防护**: ORM 自动防护
- ✅ **CORS 配置**: 跨域请求控制
---
## 📋 API 文档
启动后端后访问:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
### 主要接口
**认证**
- `POST /api/v1/auth/register` - 注册
- `POST /api/v1/auth/login` - 登录
- `GET /api/v1/auth/me` - 获取当前用户
**项目**
- `GET /api/v1/projects/` - 项目列表
- `POST /api/v1/projects/` - 创建项目
- `GET /api/v1/projects/{id}` - 项目详情
- `PUT /api/v1/projects/{id}` - 更新项目
- `DELETE /api/v1/projects/{id}` - 删除项目
**文件**
- `GET /api/v1/files/{project_id}/tree` - 目录树
- `GET /api/v1/files/{project_id}/file` - 文件内容
- `POST /api/v1/files/{project_id}/file` - 保存文件
- `POST /api/v1/files/{project_id}/upload` - 上传文件
---
## 🛠️ 开发指南
### 后端开发
1. 添加新的数据模型到 `backend/app/models/`
2. 定义 Pydantic Schema 到 `backend/app/schemas/`
3. 在 `backend/app/api/v1/` 创建路由
4. 实现业务逻辑到 `backend/app/services/`
### 前端开发
1. 在 `frontend/src/api/` 封装 API 请求
2. 在 `frontend/src/pages/` 创建页面组件
3. 在 `frontend/src/App.jsx` 添加路由
4. 使用 Zustand 管理全局状态
---
## 📝 文档
- [技术方案文档](PROJECT.md) - 完整的技术设计方案
- [数据库设计](DATABASE.md) - 数据库表结构设计
- [快速启动指南](QUICKSTART.md) - 5分钟快速上手
- [实施计划](IMPLEMENTATION_PLAN.md) - 分阶段实施计划
---
## 🔄 版本历史
> ⚠️ 版本对齐:git 发布线为 v0.9.1 → v0.9.2 → v0.9.6 → v0.9.7 → v0.9.8 → v0.9.9(当前基线,提交 `416ef48`),仓库未打 tag。下列 v1.0.0 为早期文案占位,与 git 不符;详见 SDD `docs/sdd/releases/`。
### v1.0.0 (2023-12-20,遗留占位,见上方对齐说明)
- ✅ 完整的用户认证系统
- ✅ 项目管理功能
- ✅ Markdown 文档编辑
- ✅ 文件系统管理
- ✅ 权限控制体系
- ✅ 团队协作功能
---
- 密码 bcrypt 存储;JWT 过期时间由 `ACCESS_TOKEN_EXPIRE_MINUTES` 控制。
- 文件路径全部经过规范化校验,拒绝路径穿越;上传大小与类型受限。
- `DEBUG=True` 会打印全量 SQL,**生产必须关闭**。
- `SECRET_KEY`、数据库/Redis 密码、Git Token 必须由环境注入,禁止提交到仓库;`backend/.env`、`.env` 已在 `.gitignore` 中。
- 分享链接的访问密码当前为明文存储于 `share_links.access_pass`,见发布报告 P2 项。
## 📄 许可证
Copyright © 2023 Mula.liu
---
## 🙏 致谢
感谢以下开源项目:
- [FastAPI](https://fastapi.tiangolo.com/)
- [React](https://reactjs.org/)
- [Ant Design](https://ant.design/)
- [SQLAlchemy](https://www.sqlalchemy.org/)
- [Vite](https://vitejs.dev/)
Copyright © 2026 Mula.liu
---

View File

@ -1,193 +0,0 @@
# NEX Docus
<div align="center">
**现代化的文档管理系统**
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Docker](https://img.shields.io/badge/Docker-Ready-brightgreen.svg)](DEPLOY.md)
[![Python](https://img.shields.io/badge/Python-3.9+-blue.svg)](backend/)
[![React](https://img.shields.io/badge/React-18.2-61DAFB.svg)](frontend/)
</div>
## ✨ 特性
- 📝 **Markdown 编辑器** - 强大的 Markdown 实时预览和编辑
- 📁 **项目管理** - 多项目支持,灵活的文件组织结构
- 🔐 **权限控制** - 基于 RBAC 的完善权限管理
- 🔗 **文档分享** - 支持密码保护的公开分享链接
- 👥 **协作功能** - 项目成员管理和协作编辑
- 📱 **响应式设计** - 完美支持桌面和移动端
- 🐳 **Docker 部署** - 一键容器化部署
- 🚀 **高性能** - 基于 FastAPI 和 React 构建
## 🏗️ 技术栈
### 后端
- **框架**: FastAPI (Python 3.9+)
- **数据库**: MySQL 8.0
- **缓存**: Redis 7
- **ORM**: SQLAlchemy 2.0
- **认证**: JWT (python-jose)
- **异步**: Asyncio
### 前端
- **框架**: React 18 + Vite
- **UI**: Ant Design 5
- **路由**: React Router 6
- **状态管理**: Zustand
- **Markdown**: react-markdown + rehype
- **HTTP**: Axios
## 📦 快速开始
### 使用 Docker 部署(推荐)
```bash
# 1. 克隆项目
git clone <your-repo-url>
cd "NEX Docus"
# 2. 配置环境变量
cp .env.example .env
vim .env # 修改配置
# 3. 一键部署
./deploy.sh init
```
详细部署文档请查看 [DEPLOY.md](DEPLOY.md)
### 本地开发
#### 后端开发
```bash
cd backend
# 创建虚拟环境
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 安装依赖
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 配置环境变量
cp .env.example .env
vim .env
# 初始化数据库
python scripts/init_db.py
# 启动服务
python main.py
```
后端服务将运行在 `http://localhost:8000`
API 文档: `http://localhost:8000/docs`
#### 前端开发
```bash
cd frontend
# 安装依赖
npm install
# 或使用国内镜像
npm install --registry=https://registry.npmmirror.com
# 启动开发服务器
npm run dev
```
前端服务将运行在 `http://localhost:5173`
## 📖 文档
- [部署文档](DEPLOY.md) - Docker 容器化部署指南
- [API 文档](http://localhost:8000/docs) - FastAPI 自动生成的 API 文档
- [数据库设计](DATABASE.md) - 数据库表结构说明
## 🔑 默认账号
首次部署后,系统会自动创建管理员账号:
- 用户名: `admin`(可在 .env 中配置)
- 密码: `Admin@123456`(可在 .env 中配置)
**⚠️ 重要提示:请在首次登录后立即修改默认密码!**
## 🛠️ 部署脚本
```bash
# 查看帮助
./deploy.sh help
# 初始化部署
./deploy.sh init
# 启动/停止/重启
./deploy.sh start|stop|restart
# 查看状态和日志
./deploy.sh status
./deploy.sh logs [服务名]
# 升级部署
./deploy.sh upgrade
# 数据库备份/恢复
./deploy.sh backup
./deploy.sh restore <backup_file>
```
## 📁 项目结构
```
NEX Docus/
├── backend/ # 后端服务
│ ├── app/
│ │ ├── api/ # API 路由
│ │ ├── core/ # 核心配置
│ │ ├── models/ # 数据模型
│ │ ├── schemas/ # Pydantic Schemas
│ │ └── services/ # 业务逻辑
│ ├── scripts/ # 脚本文件
│ ├── Dockerfile # 后端镜像
│ └── requirements.txt # Python 依赖
├── frontend/ # 前端应用
│ ├── src/
│ │ ├── api/ # API 请求
│ │ ├── components/ # 组件
│ │ ├── pages/ # 页面
│ │ ├── stores/ # 状态管理
│ │ └── utils/ # 工具函数
│ ├── Dockerfile # 前端镜像
│ └── package.json # npm 依赖
├── docker-compose.yml # Docker 编排
├── deploy.sh # 部署脚本
├── .env.example # 环境变量示例
└── README.md # 项目文档
```
## 🤝 贡献
欢迎提交 Issue 和 Pull Request!
## 📄 开源协议
本项目采用 [MIT](LICENSE) 协议
## 🙏 致谢
感谢以下开源项目:
- [FastAPI](https://fastapi.tiangolo.com/)
- [React](https://react.dev/)
- [Ant Design](https://ant.design/)
- [SQLAlchemy](https://www.sqlalchemy.org/)
---
**Made with ❤️ by NEX Docus Team**

View File

@ -1,158 +1,106 @@
# NEX Docus Backend
# NEX Docus 后端
NEX Docus 后端服务 - 基于 FastAPI 构建的高性能文档管理平台。
FastAPI + SQLAlchemy 2.0(异步)+ MySQL + Redis 的文档管理后端,同时提供 RAG 知识库问答与 MCP 服务。
> **上手请先看 [`docs/quickstart.md`](../docs/quickstart.md)**(一键启动、环境变量、初始化、常见问题)。
> 部署/运维见 [`docs/deploy/README.md`](../docs/deploy/README.md),表结构见 [`docs/database.md`](../docs/database.md)。
> 本文件只描述**后端代码内部结构**与开发约定。
## 技术栈
- **框架**: FastAPI 0.109+
- **数据库**: MySQL 5.7.5+ (通过 SQLAlchemy 2.0 异步 ORM)
- **缓存**: Redis
- **认证**: JWT (python-jose)
- **密码加密**: bcrypt (passlib)
- **文件处理**: aiofiles (异步文件 I/O)
| 用途 | 选型 |
| --- | --- |
| Web 框架 | FastAPI + Uvicorn |
| ORM | SQLAlchemy 2.0 异步(aiomysql),Alembic 迁移 |
| 数据库 / 缓存 | MySQL 8(utf8mb4)/ Redis |
| 认证 | JWT(python-jose)+ bcrypt(passlib) |
| 全文检索 | Whoosh 3 + jieba 分词 |
| 向量检索 | ZVec(远端 Embedding 或本地 Sentence-Transformers) |
| 文件与导出 | aiofiles、自研 Markdown/PDF 导出服务 |
| Git 同步 | 通过命令行调用 `git`(主机需自行安装) |
| 测试 | pytest + pytest-asyncio |
## 项目结构
## 目录结构
```
backend/
├── app/
│ ├── api/
│ │ └── v1/ # API 路由(v1 版本)
│ │ ├── auth.py # 用户认证
│ │ ├── projects.py # 项目管理
│ │ └── files.py # 文件系统
│ ├── core/ # 核心配置
│ │ ├── config.py # 应用配置
│ │ ├── database.py # 数据库连接
│ │ ├── security.py # 安全工具
│ │ └── deps.py # 依赖注入
│ ├── models/ # 数据库模型
│ ├── schemas/ # Pydantic Schemas
│ ├── services/ # 业务逻辑
│ ├── middleware/ # 中间件
│ └── utils/ # 工具函数
├── scripts/ # 脚本文件
│ ├── init_database.sql # 数据库初始化 SQL
│ └── init_db.py # 数据库初始化 Python 脚本
├── tests/ # 测试文件
├── main.py # 应用入口
├── requirements.txt # 依赖包
└── .env # 环境配置
│ ├── api/v1/ # HTTP 路由(前缀 /api/v1),__init__.py 汇总注册
│ ├── core/ # config / database / deps / security / enums / migrations / redis_client
│ ├── mcp/ # MCP server 与请求上下文(挂载在 /mcp)
│ ├── models/ # SQLAlchemy 模型(__init__.py 必须导入全部模型,见下方陷阱)
│ ├── schemas/ # Pydantic Schema 与统一响应包装
│ └── services/ # 业务逻辑:project/file/search/rag/zvec/git/vectorization/notification/log/storage/llm…
├── scripts/ # init_db.py(建表+种子数据)、generate_password.py,详见 scripts/README.md
├── tests/ # pytest 用例
├── models/ # 本地向量模型目录(gitignore,需自行放置 HF 模型,如 m3e-small)
├── main.py # 应用入口:/api/v1 路由、/mcp 挂载、/ 与 /health
├── requirements.txt # 运行依赖
├── requirements-dev.txt # 开发/测试依赖(含 pytest)
├── pytest.ini # testpaths=tests, pythonpath=.
└── .env # 本地配置(不入库;键名见下表)
```
## 快速开始
## 路由一览
### 1. 安装依赖
`main.py` 暴露:`GET /`(名称/版本/状态)、`GET /health`(`{"status":"healthy"}`)、`/docs`、`/openapi.json`、`/mcp`(MCP Streamable HTTP,需 `X-Bot-Id` / `X-Bot-Secret`)。
`app/api/v1/__init__.py` 注册的子路由前缀:
| 前缀 | 模块 | 说明 |
| --- | --- | --- |
| `/auth` | auth | 登录、当前用户、资料/密码/头像、MCP 凭证签发与轮换 |
| `/projects` | projects | 项目 CRUD、成员、所有权转移、分享开关、Git pull/push 与目录选择 |
| (同 `/projects/{id}/git-repos`) | git_repos | 仓库配置的增删改查与连通性测试(路由内部自带前缀) |
| `/files` | files | 目录树、读写文件、文件操作、上传、导入导出、PDF 导出、文档与静态资源流式访问 |
| `/preview` | preview | 项目预览 |
| `/shares` | shares | 分享链接创建/校验/访问 |
| `/search` | search | 全文 + 向量双引擎检索 |
| `/chat` | chat | 会话管理、SSE 流式问答、引用与中断 |
| `/llm-model-configs` | llm_model_configs | chat / embedding 模型配置与连通性测试 |
| `/dashboard` | dashboard | 管理员统计 |
| `/users` `/roles` `/role-permissions` `/menu` | users/roles/role_permissions/menu | 用户、角色、角色权限、菜单树 |
| `/notifications` | notifications | 站内通知 |
| `/logs` | logs | 系统日志查询 |
## 环境变量(`backend/.env`)
完整键表与说明见 [`docs/quickstart.md`](../docs/quickstart.md);此处只列必填项与常见坑:
- 必填:`DB_HOST` `DB_USER` `DB_PASSWORD` `DB_NAME`、`REDIS_HOST` `REDIS_PASSWORD`、`SECRET_KEY`。
- 文件存储:`STORAGE_ROOT` / `PROJECTS_PATH` / `USERS_PATH` / `TEMP_PATH`(**本地开发用这组绝对路径**;Docker 部署用根 `.env` 的 `STORAGE_PATH`,两套体系不同,别混用)。
- `DEBUG=True` 时 SQLAlchemy 会打印全量 SQL,**生产必须 `false`**。
- `DB_PASSWORD` 含特殊字符无需手动转义(连接串构造时已 `quote_plus`)。
> ⚠️ **不要在代码、文档或提交里写真实环境的账号密码。** 需要示例时用占位值(如 `password_change_me`)。
## 开发命令
```bash
# 激活虚拟环境
source venv/bin/activate # macOS/Linux
# 或
venv\Scripts\activate # Windows
cd backend
python3 -m venv venv
./venv/bin/pip install -r requirements-dev.txt # 含运行依赖 + pytest
cp .env.example .env # 若无,按 docs/quickstart.md 的键表创建
# 安装依赖
pip install -r requirements.txt
./venv/bin/python scripts/init_db.py # 建表 + 种子数据(幂等)
./venv/bin/python scripts/generate_password.py # 生成 bcrypt 哈希
./venv/bin/python -m pytest # 单元测试
./venv/bin/uvicorn main:app --reload --port 8000 # 单独起服务(推荐用仓库根 ./scripts/start.sh)
```
### 2. 配置环境变量
## 必须知道的实现约定
编辑 `.env` 文件,配置数据库连接等信息。
1. **新增模型要在 `app/models/__init__.py` 里导入。** 只建表逻辑而不导入,`Base.metadata` 就看不到该表,`init_db.py` 会**静默漏建表**,线上表现为 `Table ... doesn't exist`。
2. **项目权限只走 `app/services/project_service.py`**(`require_project_read_access` / `require_project_write_access` / `normalize_project_role`)。不要在路由里手写角色判断:历史数据里 `project_members.role` 存在大写(`ADMIN`/`EDITOR`/`VIEWER`),直接 `== "admin"` 比较会静默失效。返回给前端的角色必须先 `normalize_project_role`。
3. **异步 Session 配了 `expire_on_commit=False`**,但 `onupdate=func.now()` 的列(如 `updated_at`)在 UPDATE 提交后仍会被标记过期;提交后若还要序列化整个 ORM 对象,**必须 `await db.refresh(obj)`**,否则会在异步上下文触发同步 IO 抛 `MissingGreenlet`(HTTP 500)。
4. **统一响应**用 `app/schemas/response.py` 的 `success_response()`;业务错误抛 `HTTPException` 并用 4xx 表达(不要用 500 表达"参数/状态不合法")。
5. **SSE 接口**(`/chat`)要求网关关闭响应缓冲,见 `docs/deploy/README.md` 的 nginx 示例。
6. **本地向量模型**放在 `backend/models/<模型名>/`(含 `config.json`、`1_Pooling/config.json`、`model.safetensors` 或 `pytorch_model.bin`),由 `LocalEmbeddingService` 扫描列出;该目录已 gitignore,不要提交。
### 3. 初始化数据库
## 测试
```bash
# 方式一:使用 SQL 脚本(推荐)
mysql -h10.100.51.51 -uroot -pUnis@321 < scripts/init_database.sql
# 方式二:使用 Python 脚本(仅创建表结构)
python scripts/init_db.py
cd backend && ./venv/bin/python -m pytest
```
### 4. 启动服务
```bash
# 开发模式(自动重载)
python main.py
# 或使用 uvicorn
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
服务启动后,访问:
- API 文档: http://localhost:8000/docs
- 健康检查: http://localhost:8000/health
## API 接口
### 认证相关 (`/api/v1/auth`)
- `POST /register` - 用户注册
- `POST /login` - 用户登录
- `GET /me` - 获取当前用户信息
- `POST /change-password` - 修改密码
### 项目管理 (`/api/v1/projects`)
- `GET /` - 获取我的项目列表
- `POST /` - 创建新项目
- `GET /{project_id}` - 获取项目详情
- `PUT /{project_id}` - 更新项目信息
- `DELETE /{project_id}` - 删除项目(归档)
- `GET /{project_id}/members` - 获取项目成员
- `POST /{project_id}/members` - 添加项目成员
### 文件系统 (`/api/v1/files`)
- `GET /{project_id}/tree` - 获取项目目录树
- `GET /{project_id}/file?path=xxx` - 获取文件内容
- `POST /{project_id}/file` - 保存文件内容
- `POST /{project_id}/file/operate` - 文件操作(重命名/删除/创建)
- `POST /{project_id}/upload` - 上传文件(图片/附件)
- `GET /{project_id}/assets/{subfolder}/{filename}` - 获取资源文件
## 默认账号
初始化数据库后,会创建默认管理员账号:
- 用户名: `admin`
- 密码: `admin123`
**⚠️ 生产环境请立即修改默认密码!**
## 开发指南
### 代码风格
遵循 PEP 8 代码规范。
### 添加新的 API 端点
1. 在 `app/api/v1/` 下创建新的路由文件
2. 在 `app/api/v1/__init__.py` 中注册路由
3. 在 `app/schemas/` 中定义 Pydantic Schema
4. 在 `app/services/` 中实现业务逻辑
### 数据库迁移
使用 Alembic 进行数据库迁移:
```bash
# 生成迁移脚本
alembic revision --autogenerate -m "描述"
# 执行迁移
alembic upgrade head
```
## 安全说明
1. **路径安全**: 所有文件系统操作都经过路径安全检查,防止路径穿越攻击
2. **认证鉴权**: 使用 JWT Token 认证,所有需要登录的接口都受保护
3. **权限控制**: 实现了项目级别的权限控制(owner/admin/editor/viewer)
4. **密码加密**: 使用 bcrypt 加密存储密码
5. **SQL 注入**: 使用 SQLAlchemy ORM,自动防止 SQL 注入
## 许可证
Copyright © 2023 Mula.liu
覆盖范围(v1.0.0):项目权限与角色归一化、Git 服务命令构造、搜索服务、模型与向量配置、RAG 引用解析。**前端目前没有自动化测试**,界面回归需手工验证,重点清单见 [`docs/sdd/releases/v1.0.0.md`](../docs/sdd/releases/v1.0.0.md)。

View File

@ -18,6 +18,7 @@ from app.models.project import Project, ProjectMember
from app.models.log import OperationLog
from app.core.enums import OperationType, ResourceType
from app.schemas.response import success_response
from app.services.project_service import normalize_project_role
router = APIRouter()
@ -167,7 +168,7 @@ async def get_personal_stats(
"id": project.id,
"name": project.name,
"description": project.description,
"role": member.role,
"role": normalize_project_role(member.role),
"joined_at": member.joined_at.isoformat() if member.joined_at else None,
}
for project, member in recent_shared_projects_rows

View File

@ -19,7 +19,6 @@ from app.core.deps import get_current_user, get_user_from_token_or_query
from app.models.user import User
from app.models.log import OperationLog
from app.models.share import ShareLink
from app.models.project import Project, ProjectMember
from app.schemas.file import (
FileTreeNode,
FileSaveRequest,
@ -40,45 +39,6 @@ from app.core.enums import OperationType
router = APIRouter()
async def check_project_access(
project_id: int,
current_user: User,
db: AsyncSession,
require_write: bool = False
):
"""检查项目访问权限"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查是否是项目所有者
if project.owner_id == current_user.id:
return project
# 检查是否是项目成员
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
)
)
member = member_result.scalar_one_or_none()
if not member:
if project.is_public == 1 and not require_write:
return project
raise HTTPException(status_code=403, detail="无权访问该项目")
# 如果需要写权限,检查成员角色
if require_write and member.role == "viewer":
raise HTTPException(status_code=403, detail="无写入权限")
return project
def annotate_shared_files(tree: List[FileTreeNode], shared_paths: set[str]) -> None:
"""为文件树节点补充分享状态"""
for node in tree:
@ -114,19 +74,8 @@ async def get_project_tree(
}
annotate_shared_files(tree, shared_paths)
# 获取当前用户角色
user_role = "owner" # 默认是所有者
if project.owner_id != current_user.id:
# 查询成员角色
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
)
)
member = member_result.scalar_one_or_none()
if member:
user_role = member.role
# user_role 直接来自 require_project_read_access(已做历史大小写归一化),
# 不要在这里再查一次成员表:重复查询曾导致前端拿到 VIEWER 这类历史值后判断失效。
return success_response(data={
"tree": tree,

View File

@ -1,6 +1,8 @@
"""
通知管理 API (Redis版)
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Union
from datetime import datetime
@ -15,6 +17,8 @@ from app.schemas.notification import (
from app.schemas.response import success_response
from app.services.notification_service import notification_service
logger = logging.getLogger(__name__)
router = APIRouter()
@ -58,7 +62,7 @@ async def get_notifications(
}
except Exception as e:
# 降级处理,防止 500
print(f"Error fetching notifications: {e}")
logger.warning("读取通知列表失败,已降级为空列表: %s", e, exc_info=True)
return {
"code": 200,
"message": "success",
@ -78,7 +82,7 @@ async def get_unread_count(
count = await notification_service.get_unread_count(current_user.id)
return success_response(data={"unread_count": count})
except Exception as e:
print(f"Error fetching unread count: {e}")
logger.warning("读取未读通知数量失败,已降级为 0: %s", e, exc_info=True)
return success_response(data={"unread_count": 0})
@ -91,7 +95,7 @@ async def get_unread_by_project(
counts = await notification_service.get_unread_count_by_project(current_user.id)
return success_response(data={"unread_by_project": counts})
except Exception as e:
print(f"Error fetching unread by project: {e}")
logger.warning("按项目统计未读通知失败,已降级为空: %s", e, exc_info=True)
return success_response(data={"unread_by_project": {}})
@ -105,7 +109,7 @@ async def mark_project_read(
count = await notification_service.mark_project_read(current_user.id, project_id)
return success_response(data={"marked": count}, message="项目通知已标记为已读")
except Exception as e:
print(f"Error marking project read: {e}")
logger.warning("标记项目通知已读失败: %s", e, exc_info=True)
return success_response(message="操作完成")

View File

@ -30,7 +30,12 @@ from app.services.storage import storage_service
from app.services.log_service import log_service
from app.services.git_service import git_service
from app.services.notification_service import notification_service
from app.services.project_service import normalize_project_role, require_project_roles
from app.services.project_service import (
normalize_project_role,
require_project_read_access,
require_project_roles,
serialize_project,
)
from app.core.enums import OperationType, ResourceType
router = APIRouter()
@ -281,31 +286,20 @@ async def get_project(
db: AsyncSession = Depends(get_db)
):
"""获取项目详情"""
# 查询项目
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
# 检查权限(项目所有者或成员可访问)
if project.owner_id != current_user.id:
member_result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id
# 权限与角色统一走 project_service,避免与其他接口出现两套判定
project, user_role = await require_project_read_access(
db, project_id, current_user, allow_public=True
)
)
member = member_result.scalar_one_or_none()
if not member and project.is_public != 1:
raise HTTPException(status_code=403, detail="无权访问该项目")
# 增加访问次数 (简单计数)
project.visit_count += 1
await db.commit()
# updated_at 由数据库 onupdate 生成,commit 后该列处于过期状态,
# 必须用异步 refresh 取回,否则序列化时会在异步上下文里触发同步 IO(MissingGreenlet -> 500)
await db.refresh(project)
project_data = ProjectResponse.from_orm(project)
return success_response(data=project_data.dict())
project_data = serialize_project(project, user_role=user_role)
return success_response(data=project_data)
@router.put("/{project_id}", response_model=dict)
@ -690,7 +684,7 @@ async def update_project_member_role(
if not target_member:
raise HTTPException(status_code=404, detail="该用户不是项目成员")
old_role = target_member.role
old_role = normalize_project_role(target_member.role)
target_member.role = member_in.role
await db.commit()
await db.refresh(target_member)

View File

@ -23,6 +23,7 @@ from app.schemas.project import FileShareCreate
from app.schemas.response import success_response
from app.services.log_service import log_service
from app.services.pdf_service import pdf_service
from app.services.project_service import normalize_project_role
from app.services.search_service import search_service
from app.services.storage import storage_service
@ -65,7 +66,7 @@ async def ensure_project_member(project: Project, current_user: User, db: AsyncS
member = member_result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=403, detail="无权访问该项目")
return member.role
return normalize_project_role(member.role)
async def get_share_by_code_or_404(share_code: str, share_type: Optional[str], db: AsyncSession) -> ShareLink:

View File

@ -12,7 +12,7 @@ class Settings(BaseSettings):
# 应用信息
APP_NAME: str = "NEX Docus"
APP_VERSION: str = "0.9.9"
APP_VERSION: str = "1.0.0"
DEBUG: bool = True
# 服务器配置
@ -84,6 +84,32 @@ class Settings(BaseSettings):
env_file = ".env"
case_sensitive = True
def security_warnings(self) -> List[str]:
"""启动自检:返回仍在使用的占位/默认敏感配置说明(不阻断启动,仅告警)。"""
warnings: List[str] = []
if self.SECRET_KEY in INSECURE_SECRET_KEYS or len(self.SECRET_KEY) < 16:
warnings.append(
"SECRET_KEY 仍是占位值或过短,任何人都可伪造 JWT。"
"请在 .env 中设置为随机值:openssl rand -hex 32"
)
if self.DEFAULT_USER_PASSWORD in DEFAULT_INSECURE_PASSWORDS:
warnings.append(
"DEFAULT_USER_PASSWORD 仍是仓库内置默认值,管理员新建的账号可被猜到。"
"请在部署配置中改为随机初始密码"
)
return warnings
# 仓库/模板里出现过的占位值:命中即视为未修改
INSECURE_SECRET_KEYS = {
"your-secret-key-change-me-in-production",
"your-secret-key-change-me-in-production-use-openssl-rand-hex-32",
"change_me",
"change-me",
"secret",
}
DEFAULT_INSECURE_PASSWORDS = {"User@123", "User@123456"}
# 创建配置实例
settings = Settings()

View File

@ -8,6 +8,8 @@ from app.models.menu import SystemMenu, RoleMenu
from app.models.project import Project, ProjectMember, ProjectMemberRole
from app.models.document import DocumentMeta
from app.models.document_vector import DocumentVector
from app.models.git_repo import ProjectGitRepo
from app.models.notification import Notification
from app.models.share import ShareLink
from app.models.log import OperationLog
from app.models.mcp_bot import MCPBot
@ -27,6 +29,8 @@ __all__ = [
"ProjectMemberRole",
"DocumentMeta",
"DocumentVector",
"ProjectGitRepo",
"Notification",
"ShareLink",
"OperationLog",
"MCPBot",

View File

@ -1,794 +0,0 @@
"""
LLM 提供方测试服务
"""
import asyncio
import json
import socket
import time
import urllib.error
import urllib.parse
import urllib.request
import ssl
from typing import Any, Dict, List, Optional
PROVIDER_CATALOG: List[Dict[str, str]] = [
{
"value": "openai",
"label": "OpenAI",
"default_endpoint_url": "https://api.openai.com/v1",
"protocol": "openai_compatible",
},
{
"value": "deepseek",
"label": "DeepSeek",
"default_endpoint_url": "https://api.deepseek.com/v1",
"protocol": "openai_compatible",
},
{
"value": "anthropic",
"label": "Anthropic",
"default_endpoint_url": "https://api.anthropic.com",
"protocol": "anthropic",
},
{
"value": "gemini",
"label": "Google Gemini",
"default_endpoint_url": "https://generativelanguage.googleapis.com/v1beta",
"protocol": "gemini",
},
{
"value": "dashscope",
"label": "阿里百炼",
"default_endpoint_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"protocol": "openai_compatible",
},
{
"value": "zhipu",
"label": "智谱 AI",
"default_endpoint_url": "https://open.bigmodel.cn/api/paas/v4",
"protocol": "openai_compatible",
},
{
"value": "moonshot",
"label": "Moonshot AI",
"default_endpoint_url": "https://api.moonshot.cn/v1",
"protocol": "openai_compatible",
},
{
"value": "groq",
"label": "Groq",
"default_endpoint_url": "https://api.groq.com/openai/v1",
"protocol": "openai_compatible",
},
{
"value": "openrouter",
"label": "OpenRouter",
"default_endpoint_url": "https://openrouter.ai/api/v1",
"protocol": "openai_compatible",
},
{
"value": "siliconflow",
"label": "SiliconFlow",
"default_endpoint_url": "https://api.siliconflow.cn/v1",
"protocol": "openai_compatible",
},
{
"value": "ollama",
"label": "Ollama",
"default_endpoint_url": "http://localhost:11434/v1",
"protocol": "openai_compatible",
},
{
"value": "ark",
"label": "火山方舟",
"default_endpoint_url": "https://ark.cn-beijing.volces.com/api/v3",
"protocol": "openai_compatible",
},
{
"value": "custom",
"label": "自定义兼容接口",
"default_endpoint_url": "",
"protocol": "openai_compatible",
},
]
PROVIDER_MAP = {item["value"]: item for item in PROVIDER_CATALOG}
class LLMProviderService:
"""LLM 提供方测试服务"""
@staticmethod
def get_provider_catalog() -> List[Dict[str, str]]:
return PROVIDER_CATALOG
@staticmethod
def get_provider_label(provider: Optional[str]) -> str:
if not provider:
return "自定义模型"
return PROVIDER_MAP.get(provider, {}).get("label", provider)
@staticmethod
def get_default_endpoint_url(provider: Optional[str]) -> str:
if not provider:
return ""
return PROVIDER_MAP.get(provider, {}).get("default_endpoint_url", "")
@classmethod
def build_model_name(cls, provider: Optional[str], llm_model_name: str) -> str:
model_name = (llm_model_name or "").strip()
label = cls.get_provider_label(provider)
if not model_name:
return label
return f"{label} {model_name}"
@staticmethod
def build_model_code(provider: Optional[str], llm_model_name: str) -> str:
provider_part = (provider or "custom").strip().lower()
model_part = (llm_model_name or "").strip().lower()
sanitized = []
previous_is_separator = False
for char in model_part:
if char.isalnum():
sanitized.append(char)
previous_is_separator = False
else:
if not previous_is_separator:
sanitized.append("_")
previous_is_separator = True
model_slug = "".join(sanitized).strip("_") or "model"
return f"llm_{provider_part}_{model_slug}"
@classmethod
async def test_model_connection(cls, payload: Dict[str, Any]) -> Dict[str, Any]:
provider = payload.get("provider")
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (payload.get("endpoint_url") or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (payload.get("llm_model_name") or "").strip()
api_key = (payload.get("api_key") or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 llm_model_name")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key,请填写后再测试")
timeout = int(payload.get("llm_timeout") or 120)
temperature = float(payload.get("llm_temperature") or 0.7)
top_p = float(payload.get("llm_top_p") or 0.9)
max_tokens = int(payload.get("llm_max_tokens") or 2048)
system_prompt = (payload.get("llm_system_prompt") or "").strip()
started_at = time.perf_counter()
preview = await asyncio.to_thread(
cls._send_test_request,
provider_meta.get("protocol", "openai_compatible"),
provider,
endpoint_url,
api_key,
llm_model_name,
timeout,
temperature,
top_p,
max_tokens,
system_prompt,
)
latency_ms = int((time.perf_counter() - started_at) * 1000)
return {
"provider": provider,
"endpoint_url": endpoint_url,
"llm_model_name": llm_model_name,
"latency_ms": latency_ms,
"preview": preview[:200],
}
@classmethod
def _send_test_request(
cls,
protocol: str,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
) -> str:
if protocol == "anthropic":
return cls._test_anthropic(
endpoint_url,
api_key,
llm_model_name,
timeout,
temperature,
top_p,
max_tokens,
system_prompt,
)
if protocol == "gemini":
return cls._test_gemini(
endpoint_url,
api_key,
llm_model_name,
timeout,
temperature,
top_p,
max_tokens,
system_prompt,
)
return cls._test_openai_compatible(
provider,
endpoint_url,
api_key,
llm_model_name,
timeout,
temperature,
top_p,
max_tokens,
system_prompt,
)
@classmethod
def _test_openai_compatible(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
) -> str:
url = cls._join_endpoint(endpoint_url, "/chat/completions")
headers = {
"Content-Type": "application/json",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = {
"model": llm_model_name,
"messages": cls._build_openai_messages(system_prompt),
"temperature": temperature,
"top_p": top_p,
"max_tokens": min(max_tokens, 256),
"stream": False,
}
response = cls._request_json(url, headers, payload, timeout)
choices = response.get("choices") or []
if not choices:
raise ValueError("测试请求已发送,但未收到模型返回内容")
content = choices[0].get("message", {}).get("content", "")
if isinstance(content, list):
content = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in content
)
content = str(content).strip()
if not content:
raise ValueError("模型返回成功,但内容为空")
return content
@classmethod
def _test_anthropic(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
) -> str:
url = cls._join_endpoint(endpoint_url, "/v1/messages")
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
payload = {
"model": llm_model_name,
"max_tokens": min(max_tokens, 256),
"temperature": temperature,
"top_p": top_p,
"messages": [
{
"role": "user",
"content": "请只回复“连接测试成功”。",
}
],
}
if system_prompt:
payload["system"] = system_prompt
response = cls._request_json(url, headers, payload, timeout)
content = response.get("content") or []
texts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
texts.append(item.get("text", ""))
preview = "".join(texts).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
@classmethod
def _test_gemini(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
) -> str:
model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}"
encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/"))
url = f"{endpoint_url.rstrip('/')}/{encoded_model_path}:generateContent?key={urllib.parse.quote(api_key)}"
headers = {
"Content-Type": "application/json",
}
payload = {
"contents": [
{
"role": "user",
"parts": [{"text": "请只回复“连接测试成功”。"}],
}
],
"generationConfig": {
"temperature": temperature,
"topP": top_p,
"maxOutputTokens": min(max_tokens, 256),
},
}
if system_prompt:
payload["systemInstruction"] = {
"parts": [{"text": system_prompt}],
}
response = cls._request_json(url, headers, payload, timeout)
candidates = response.get("candidates") or []
if not candidates:
raise ValueError("测试请求已发送,但未收到模型返回内容")
parts = candidates[0].get("content", {}).get("parts", [])
preview = "".join(
part.get("text", "") for part in parts if isinstance(part, dict)
).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
@staticmethod
def _build_openai_messages(system_prompt: str) -> List[Dict[str, str]]:
messages: List[Dict[str, str]] = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": "请只回复“连接测试成功”。"})
return messages
@staticmethod
def _join_endpoint(base_url: str, suffix: str) -> str:
normalized = base_url.rstrip("/")
if normalized.endswith(suffix):
return normalized
return f"{normalized}{suffix}"
@classmethod
def _request_json(
cls,
url: str,
headers: Dict[str, str],
payload: Dict[str, Any],
timeout: int,
) -> Dict[str, Any]:
request = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
ctx = None
if os.getenv("DISABLE_SSL_VERIFY", "").lower() in ("1", "true", "yes"):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
with urllib.request.urlopen(request, timeout=timeout, context=ctx) as response:
body = response.read().decode("utf-8")
if not body:
return {}
return json.loads(body)
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="ignore")
message = cls._extract_error_message(error_body) or error_body[:300] or str(exc)
raise ValueError(f"模型测试失败(HTTP {exc.code}):{message}") from exc
except urllib.error.URLError as exc:
reason = exc.reason
if isinstance(reason, socket.timeout):
raise ValueError("模型测试超时,请检查网络或调大超时时间") from exc
raise ValueError(f"模型测试失败:{reason}") from exc
except socket.timeout as exc:
raise ValueError("模型测试超时,请检查网络或调大超时时间") from exc
except json.JSONDecodeError as exc:
raise ValueError("模型服务返回了无法解析的响应,请检查接口地址是否正确") from exc
@staticmethod
def _extract_error_message(error_body: str) -> Optional[str]:
if not error_body:
return None
try:
payload = json.loads(error_body)
except json.JSONDecodeError:
return error_body.strip()
if isinstance(payload, dict):
if isinstance(payload.get("error"), dict):
return payload["error"].get("message") or payload["error"].get("type")
return payload.get("message") or payload.get("detail")
return None
# ==================== 对话文本生成(RAG 调用) ====================
@classmethod
async def generate_text(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, Any]],
) -> str:
"""根据多轮对话消息生成回复文本(供知识库 RAG 使用)"""
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (llm_model_name or "").strip()
api_key = (api_key or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 llm_model_name")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key,请填写后再调用")
normalized = cls._normalize_messages(messages)
if not normalized:
raise ValueError("缺少有效的对话消息")
protocol = provider_meta.get("protocol", "openai_compatible")
return await asyncio.to_thread(
cls._chat_completion,
protocol,
endpoint_url,
api_key,
llm_model_name,
int(timeout or 120),
float(temperature or 0.7),
float(top_p or 0.9),
int(max_tokens or 2048),
(system_prompt or "").strip(),
normalized,
)
@staticmethod
def _normalize_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, str]]:
normalized: List[Dict[str, str]] = []
for item in messages or []:
if not isinstance(item, dict):
continue
role = str(item.get("role") or "").strip().lower()
if role not in {"system", "user", "assistant"}:
continue
content = item.get("content", "")
if isinstance(content, list):
content = "".join(
part.get("text", "") if isinstance(part, dict) else str(part)
for part in content
)
content = str(content).strip()
if not content:
continue
normalized.append({"role": role, "content": content})
return normalized
@classmethod
def _chat_completion(
cls,
protocol: str,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
if protocol == "anthropic":
return cls._chat_anthropic(
endpoint_url, api_key, llm_model_name, timeout,
temperature, top_p, max_tokens, system_prompt, messages,
)
if protocol == "gemini":
return cls._chat_gemini(
endpoint_url, api_key, llm_model_name, timeout,
temperature, top_p, max_tokens, system_prompt, messages,
)
return cls._chat_openai_compatible(
endpoint_url, api_key, llm_model_name, timeout,
temperature, top_p, max_tokens, system_prompt, messages,
)
@classmethod
def _chat_openai_compatible(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
url = cls._join_endpoint(endpoint_url, "/chat/completions")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload_messages: List[Dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
payload_messages.extend(messages)
payload = {
"model": llm_model_name,
"messages": payload_messages,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
"stream": False,
}
response = cls._request_json(url, headers, payload, timeout)
choices = response.get("choices") or []
if not choices:
raise ValueError("请求已发送,但未收到模型返回内容")
content = choices[0].get("message", {}).get("content", "")
if isinstance(content, list):
content = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in content
)
content = str(content).strip()
if not content:
raise ValueError("模型返回成功,但内容为空")
return content
@classmethod
def _chat_anthropic(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
url = cls._join_endpoint(endpoint_url, "/v1/messages")
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
payload = {
"model": llm_model_name,
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"messages": [
{"role": m["role"], "content": [{"type": "text", "text": m["content"]}]}
for m in messages
if m["role"] in {"user", "assistant"}
],
}
if system_prompt:
payload["system"] = system_prompt
response = cls._request_json(url, headers, payload, timeout)
content = response.get("content") or []
texts = [
item.get("text", "")
for item in content
if isinstance(item, dict) and item.get("type") == "text"
]
preview = "".join(texts).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
@classmethod
def _chat_gemini(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}"
encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/"))
url = f"{endpoint_url.rstrip('/')}/{encoded_model_path}:generateContent?key={urllib.parse.quote(api_key)}"
headers = {"Content-Type": "application/json"}
role_map = {"user": "user", "assistant": "model"}
contents = [
{"role": role_map[m["role"]], "parts": [{"text": m["content"]}]}
for m in messages
if m["role"] in role_map
]
payload = {
"contents": contents,
"generationConfig": {
"temperature": temperature,
"topP": top_p,
"maxOutputTokens": max_tokens,
},
}
if system_prompt:
payload["systemInstruction"] = {"parts": [{"text": system_prompt}]}
response = cls._request_json(url, headers, payload, timeout)
candidates = response.get("candidates") or []
if not candidates:
raise ValueError("请求已发送,但未收到模型返回内容")
parts = candidates[0].get("content", {}).get("parts", [])
preview = "".join(
part.get("text", "") for part in parts if isinstance(part, dict)
).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
# ==================== Embedding 向量生成与测试 ====================
@classmethod
async def generate_embedding(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
text: str,
timeout: int = 60,
dimension: Optional[int] = None,
) -> List[float]:
"""调用 OpenAI 兼容的 /embeddings 接口生成单条文本向量"""
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (llm_model_name or "").strip()
api_key = (api_key or "").strip()
text = (text or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 embedding 模型标识")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key,请填写后再调用")
if not text:
raise ValueError("待向量化文本为空")
vectors = await asyncio.to_thread(
cls._embeddings_request,
endpoint_url,
api_key,
llm_model_name,
[text[:8000]],
int(timeout or 60),
dimension,
)
if not vectors:
raise ValueError("Embedding 接口返回为空")
return vectors[0]
@classmethod
def _embeddings_request(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
inputs: List[str],
timeout: int,
dimension: Optional[int],
) -> List[List[float]]:
url = cls._join_endpoint(endpoint_url, "/embeddings")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload: Dict[str, Any] = {
"model": llm_model_name,
"input": inputs,
}
if dimension:
payload["dimensions"] = dimension
response = cls._request_json(url, headers, payload, timeout)
data = response.get("data") or []
if not data:
raise ValueError("Embedding 请求已发送,但未收到向量数据")
vectors: List[List[float]] = []
for item in data:
embedding = item.get("embedding") if isinstance(item, dict) else None
if embedding:
vectors.append([float(x) for x in embedding])
if not vectors:
raise ValueError("Embedding 返回结果中未解析到向量")
return vectors
@classmethod
async def test_embedding_connection(cls, payload: Dict[str, Any]) -> Dict[str, Any]:
"""测试 embedding 模型连通性,并返回实际向量维度"""
provider = payload.get("provider")
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (payload.get("endpoint_url") or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (payload.get("llm_model_name") or "").strip()
api_key = (payload.get("api_key") or "").strip()
timeout = int(payload.get("llm_timeout") or 60)
dimension = payload.get("embedding_dimension")
started_at = time.perf_counter()
vector = await cls.generate_embedding(
provider,
endpoint_url,
api_key,
llm_model_name,
"连接测试",
timeout=timeout,
dimension=int(dimension) if dimension else None,
)
latency_ms = int((time.perf_counter() - started_at) * 1000)
return {
"provider": provider,
"endpoint_url": endpoint_url,
"llm_model_name": llm_model_name,
"latency_ms": latency_ms,
"dimension": len(vector),
"preview": f"成功生成 {len(vector)} 维向量",
}

View File

@ -3,6 +3,7 @@ FastAPI 主应用
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import logging
from contextlib import asynccontextmanager
from app.core.config import settings
from app.core.redis_client import init_redis, close_redis
@ -19,9 +20,15 @@ except RuntimeError:
mcp_session_manager = None
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
# 敏感配置自检:命中占位值时告警(不阻断启动,避免影响存量部署)
for warning in settings.security_warnings():
logger.warning("[安全自检] %s", warning)
# 启动时补齐数据库新增列(幂等),避免存量库缺少新字段
await migrate_schema()
# 启动时初始化 Redis

View File

@ -0,0 +1,4 @@
[pytest]
testpaths = tests
pythonpath = .
addopts = -q

View File

@ -0,0 +1,6 @@
# 开发/测试依赖(在生产依赖之外额外安装)
# ./venv/bin/pip install -r requirements-dev.txt
# ./venv/bin/python -m pytest -q
-r requirements.txt
pytest==8.3.4
pytest-asyncio==0.25.0

View File

@ -0,0 +1,25 @@
# backend/scripts —— 随镜像发布的运行时脚本
这里的脚本会被 `backend/Dockerfile` 的 `COPY . .` 打进后端镜像,并在容器启动时执行:
```dockerfile
CMD ["sh", "-c", "python scripts/init_db.py && exec uvicorn main:app --host 0.0.0.0 --port 8000"]
```
因此它们**必须留在 `backend/` 内**(Compose 的 build context 就是 `./backend`,
无法 COPY 仓库根的 `scripts/`)。仓库层面的运维脚本请放到根目录 [`scripts/`](../../scripts/)。
| 脚本 | 用途 |
| --- | --- |
| `init_db.py` | 幂等地建表(`Base.metadata.create_all`)、执行结构迁移(`app.core.migrations.migrate_schema`)、初始化角色/菜单/管理员 |
| `generate_password.py` | 生成管理员密码的 bcrypt 哈希,用于手工建号等运维场景 |
## 数据库结构如何演进
1. 新增表:在 `backend/app/models/` 定义模型即可,`create_all` 会自动建表。
2. 新增列:在 `backend/app/core/migrations.py` 追加一条
`(表名, 列名, "ALTER TABLE ... ADD COLUMN ...")`,启动时幂等补齐。
3. 新增种子数据(菜单/角色等):写进 `init_db.py`,保证全新部署与存量部署结果一致。
> 历史遗留的一次性 `*.sql` / `add_*.py` 补丁脚本已在 v0.9.9 → v1.0.0 整理中删除,
> 它们的语义已完全被上述幂等机制覆盖。

View File

@ -1,5 +0,0 @@
ALTER TABLE projects
ADD COLUMN git_repo_url VARCHAR(255) COMMENT 'Git仓库地址',
ADD COLUMN git_branch VARCHAR(50) DEFAULT 'main' COMMENT 'Git分支',
ADD COLUMN git_username VARCHAR(100) COMMENT 'Git用户名',
ADD COLUMN git_token VARCHAR(255) COMMENT 'Git访问令牌/密码';

View File

@ -1,3 +0,0 @@
-- 为项目Git仓库表增加“同步目录”字段(空=整个仓库)
ALTER TABLE project_git_repos
ADD COLUMN sync_path VARCHAR(255) DEFAULT '' COMMENT '同步目录(空=整个仓库)' AFTER is_default;

View File

@ -1,103 +0,0 @@
"""
添加角色权限管理菜单到数据库
"""
import sys
import asyncio
from pathlib import Path
# 添加项目根目录到 Python 路径
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy import text
from app.core.database import async_session
async def add_role_permissions_menu():
"""添加角色权限管理菜单"""
print("正在添加角色权限管理菜单...")
async with async_session() as session:
# 1. 检查菜单是否已存在
result = await session.execute(
text("SELECT COUNT(*) FROM system_menus WHERE id = 14")
)
count = result.scalar()
if count > 0:
print(" 菜单已存在,跳过添加")
return
# 2. 插入菜单项
await session.execute(
text("""
INSERT INTO system_menus (
id, parent_id, menu_name, menu_code, menu_type,
path, component, icon, sort_order, visible, status,
created_at, updated_at
) VALUES (
14, 4, '角色权限管理', 'system:role_permissions', 1,
'/role-permissions', 'RolePermissions', 'SafetyOutlined',
6, 1, 1, NOW(), NOW()
)
""")
)
print("✓ 菜单项添加成功")
# 3. 为超级管理员角色分配菜单权限
result = await session.execute(
text("SELECT id FROM roles WHERE role_code = 'super_admin'")
)
super_admin_role_id = result.scalar()
if super_admin_role_id:
await session.execute(
text("""
INSERT INTO role_menus (role_id, menu_id, created_at)
VALUES (:role_id, 14, NOW())
"""),
{"role_id": super_admin_role_id}
)
print(f"✓ 已为超级管理员角色分配权限")
# 4. 为管理员角色分配菜单权限
result = await session.execute(
text("SELECT id FROM roles WHERE role_code = 'admin'")
)
admin_role_id = result.scalar()
if admin_role_id:
await session.execute(
text("""
INSERT INTO role_menus (role_id, menu_id, created_at)
VALUES (:role_id, 14, NOW())
"""),
{"role_id": admin_role_id}
)
print(f"✓ 已为管理员角色分配权限")
await session.commit()
print("\n✓ 角色权限管理菜单添加完成!")
async def main():
"""主函数"""
print("=" * 60)
print("添加角色权限管理菜单")
print("=" * 60)
print()
try:
await add_role_permissions_menu()
print()
print("=" * 60)
print("✓ 操作完成!")
print("=" * 60)
except Exception as e:
print(f"\n✗ 操作失败: {str(e)}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

View File

@ -1,73 +0,0 @@
-- 添加角色权限管理菜单
-- 执行时间: 2024-12-23
-- 1. 插入菜单项
INSERT INTO system_menus (
id,
parent_id,
menu_name,
menu_code,
menu_type,
path,
component,
icon,
sort_order,
visible,
status,
created_at,
updated_at
) VALUES (
14,
4,
'角色权限管理',
'system:role_permissions',
1,
'/role-permissions',
'RolePermissions',
'SafetyOutlined',
6,
1,
1,
NOW(),
NOW()
);
-- 2. 为超级管理员角色分配该菜单权限
INSERT INTO role_menus (role_id, menu_id, created_at)
SELECT r.id, 14, NOW()
FROM roles r
WHERE r.role_code = 'super_admin'
AND NOT EXISTS (
SELECT 1 FROM role_menus rm
WHERE rm.role_id = r.id AND rm.menu_id = 14
);
-- 3. 为管理员角色分配该菜单权限
INSERT INTO role_menus (role_id, menu_id, created_at)
SELECT r.id, 14, NOW()
FROM roles r
WHERE r.role_code = 'admin'
AND NOT EXISTS (
SELECT 1 FROM role_menus rm
WHERE rm.role_id = r.id AND rm.menu_id = 14
);
-- 验证结果
SELECT
m.id,
m.menu_name,
m.menu_code,
m.path,
CASE WHEN m.parent_id = 0 THEN '根菜单' ELSE CONCAT('子菜单 (父ID: ', m.parent_id, ')') END as menu_level
FROM system_menus m
WHERE m.id = 14;
-- 查看哪些角色拥有此菜单权限
SELECT
r.role_name,
r.role_code,
m.menu_name
FROM roles r
JOIN role_menus rm ON r.id = rm.role_id
JOIN system_menus m ON rm.menu_id = m.id
WHERE m.id = 14;

View File

@ -1,147 +0,0 @@
"""
添加用户管理和角色管理菜单到数据库
"""
import sys
import asyncio
from pathlib import Path
# 添加项目根目录到 Python 路径
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy import text
from app.core.database import async_session
async def add_user_role_menus():
"""添加用户管理和角色管理菜单"""
print("正在添加用户管理和角色管理菜单...")
async with async_session() as session:
# 1. 检查菜单是否已存在
result = await session.execute(
text("SELECT COUNT(*) FROM system_menus WHERE id IN (15, 16)")
)
count = result.scalar()
if count > 0:
print(f" 菜单已存在({count}个),跳过添加")
return
# 2. 插入用户管理菜单(ID: 15)
await session.execute(
text("""
INSERT INTO system_menus (
id, parent_id, menu_name, menu_code, menu_type,
path, component, icon, sort_order, visible, status,
created_at, updated_at
) VALUES (
15, 4, '用户管理', 'system:users', 1,
'/users', 'UserManagement', 'UserOutlined',
1, 1, 1, NOW(), NOW()
)
""")
)
print("✓ 用户管理菜单添加成功(ID: 15)")
# 3. 插入角色管理菜单(ID: 16)
await session.execute(
text("""
INSERT INTO system_menus (
id, parent_id, menu_name, menu_code, menu_type,
path, component, icon, sort_order, visible, status,
created_at, updated_at
) VALUES (
16, 4, '角色管理', 'system:roles', 1,
'/roles', 'RoleManagement', 'TeamOutlined',
2, 1, 1, NOW(), NOW()
)
""")
)
print("✓ 角色管理菜单添加成功(ID: 16)")
# 4. 更新已有菜单的sort_order,避免冲突
await session.execute(
text("""
UPDATE system_menus
SET sort_order = sort_order + 2
WHERE parent_id = 4 AND id NOT IN (15, 16) AND sort_order >= 1
""")
)
print("✓ 已调整其他子菜单的排序")
# 5. 为超级管理员角色分配这两个菜单权限
result = await session.execute(
text("SELECT id FROM roles WHERE role_code = 'super_admin'")
)
super_admin_role_id = result.scalar()
if super_admin_role_id:
# 用户管理菜单
await session.execute(
text("""
INSERT INTO role_menus (role_id, menu_id, created_at)
VALUES (:role_id, 15, NOW())
"""),
{"role_id": super_admin_role_id}
)
# 角色管理菜单
await session.execute(
text("""
INSERT INTO role_menus (role_id, menu_id, created_at)
VALUES (:role_id, 16, NOW())
"""),
{"role_id": super_admin_role_id}
)
print(f"✓ 已为超级管理员角色分配权限")
# 6. 为管理员角色分配这两个菜单权限(如果存在)
result = await session.execute(
text("SELECT id FROM roles WHERE role_code = 'admin'")
)
admin_role_id = result.scalar()
if admin_role_id:
# 用户管理菜单
await session.execute(
text("""
INSERT INTO role_menus (role_id, menu_id, created_at)
VALUES (:role_id, 15, NOW())
"""),
{"role_id": admin_role_id}
)
# 角色管理菜单
await session.execute(
text("""
INSERT INTO role_menus (role_id, menu_id, created_at)
VALUES (:role_id, 16, NOW())
"""),
{"role_id": admin_role_id}
)
print(f"✓ 已为管理员角色分配权限")
await session.commit()
print("\n✓ 用户管理和角色管理菜单添加完成!")
async def main():
"""主函数"""
print("=" * 80)
print("添加用户管理和角色管理菜单")
print("=" * 80)
print()
try:
await add_user_role_menus()
print()
print("=" * 80)
print("✓ 操作完成!现在可以在系统管理菜单中访问用户管理和角色管理了")
print("=" * 80)
except Exception as e:
print(f"\n✗ 操作失败: {str(e)}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

View File

@ -1,33 +0,0 @@
-- 创建知识库对话会话表
CREATE TABLE IF NOT EXISTS `chat_session` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '会话ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`llm_config_id` BIGINT NOT NULL COMMENT 'LLM配置ID',
`title` VARCHAR(255) NOT NULL COMMENT '会话标题',
`description` TEXT DEFAULT NULL COMMENT '会话描述',
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否激活',
`message_count` INT NOT NULL DEFAULT 0 COMMENT '消息数',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_project_id` (`project_id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_project_user` (`project_id`, `user_id`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`llm_config_id`) REFERENCES `llm_model_config`(`config_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库对话会话表';
-- 创建知识库对话消息表
CREATE TABLE IF NOT EXISTS `chat_message` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID',
`session_id` BIGINT NOT NULL COMMENT '会话ID',
`role` VARCHAR(32) NOT NULL COMMENT '角色(user/assistant)',
`content` TEXT NOT NULL COMMENT '消息内容',
`referenced_files` TEXT DEFAULT NULL COMMENT '参考文件(JSON数组)',
`tokens_used` INT DEFAULT NULL COMMENT '消耗的token数',
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已删除',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX `idx_session_id` (`session_id`),
FOREIGN KEY (`session_id`) REFERENCES `chat_session`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识库对话消息表';

View File

@ -1,17 +0,0 @@
CREATE TABLE IF NOT EXISTS `document_vector` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '向量ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径',
`chunk_index` INT NOT NULL DEFAULT 0 COMMENT '分块序号(0起),同一文件可有多个分块',
`chunk_text` TEXT DEFAULT NULL COMMENT '分块首段文本,作为点击引用时的定位锚点',
`content_hash` VARCHAR(64) DEFAULT NULL COMMENT '整个文件内容哈希值,用于判断文件是否变更',
`zvec_id` VARCHAR(256) DEFAULT NULL COMMENT 'ZVec返回的向量ID(每个分块独立)',
`zvec_response` TEXT DEFAULT NULL COMMENT 'ZVec完整响应JSON',
`status` VARCHAR(32) NOT NULL DEFAULT 'success' COMMENT '向量化状态:success/failed/pending',
`error_message` VARCHAR(500) DEFAULT NULL COMMENT '错误信息',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_project_file` (`project_id`, `file_path`),
INDEX `idx_project_file_chunk` (`project_id`, `file_path`, `chunk_index`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文档向量表';

View File

@ -1,14 +0,0 @@
CREATE TABLE `project_git_repos` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`name` VARCHAR(50) NOT NULL COMMENT '仓库别名',
`repo_url` VARCHAR(255) NOT NULL COMMENT 'Git仓库地址',
`branch` VARCHAR(50) DEFAULT 'main' COMMENT 'Git分支',
`username` VARCHAR(100) DEFAULT NULL COMMENT 'Git用户名',
`token` VARCHAR(255) DEFAULT NULL COMMENT 'Git访问令牌/密码',
`is_default` TINYINT DEFAULT 0 COMMENT '是否默认仓库',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_project_id` (`project_id`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目Git仓库表';

View File

@ -1,14 +0,0 @@
CREATE TABLE IF NOT EXISTS `mcp_bots` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Bot credential ID',
`user_id` BIGINT NOT NULL COMMENT 'Owner user ID',
`bot_id` VARCHAR(64) NOT NULL COMMENT 'External MCP bot id',
`bot_secret` VARCHAR(255) NOT NULL COMMENT 'External MCP bot secret',
`status` TINYINT DEFAULT 1 COMMENT 'Status: 0-disabled 1-enabled',
`last_used_at` DATETIME DEFAULT NULL COMMENT 'Last successful MCP access time',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Created at',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Updated at',
UNIQUE KEY `uk_mcp_bots_user_id` (`user_id`),
UNIQUE KEY `uk_mcp_bots_bot_id` (`bot_id`),
INDEX `idx_mcp_bots_status` (`status`),
CONSTRAINT `fk_mcp_bots_user_id` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MCP bot credentials';

View File

@ -1,154 +0,0 @@
"""
创建 NEX Design 项目
"""
import pymysql
import uuid
import os
from pathlib import Path
from datetime import datetime
# 数据库配置
DB_CONFIG = {
'host': '10.100.51.51',
'port': 3306,
'user': 'root',
'password': 'Unis@123',
'database': 'nex_docus',
'charset': 'utf8mb4',
}
# 项目信息
PROJECT_INFO = {
'name': 'NEX Design',
'description': 'NEX 设计文档库',
'owner_id': 1, # admin 用户的 ID
}
# 文件存储根目录
STORAGE_ROOT = '/data/nex_docus_store/projects'
def create_project():
"""创建项目"""
try:
# 生成 UUID
storage_key = str(uuid.uuid4())
print(f"生成项目 UUID: {storage_key}")
# 连接数据库
print("正在连接数据库...")
connection = pymysql.connect(**DB_CONFIG)
try:
with connection.cursor() as cursor:
# 1. 插入项目记录
print("创建项目记录...")
insert_project_sql = """
INSERT INTO `projects`
(`name`, `description`, `storage_key`, `owner_id`, `is_public`, `status`, `created_at`)
VALUES (%s, %s, %s, %s, %s, %s, %s)
"""
cursor.execute(insert_project_sql, (
PROJECT_INFO['name'],
PROJECT_INFO['description'],
storage_key,
PROJECT_INFO['owner_id'],
0, # 私有项目
1, # 活跃状态
datetime.now()
))
project_id = cursor.lastrowid
print(f"✓ 项目ID: {project_id}")
# 2. 添加项目成员(admin 作为管理员)
print("添加项目管理员...")
insert_member_sql = """
INSERT INTO `project_members`
(`project_id`, `user_id`, `role`, `joined_at`)
VALUES (%s, %s, %s, %s)
"""
cursor.execute(insert_member_sql, (
project_id,
PROJECT_INFO['owner_id'],
'admin',
datetime.now()
))
print("✓ 管理员已添加")
# 提交事务
connection.commit()
print("✓ 数据库记录创建成功")
finally:
connection.close()
# 3. 创建物理文件夹结构
print("\n创建项目文件夹...")
project_path = Path(STORAGE_ROOT) / storage_key
try:
# 创建项目根目录
project_path.mkdir(parents=True, exist_ok=True)
print(f"✓ 创建目录: {project_path}")
# 创建 _assets 目录
assets_dir = project_path / "_assets"
assets_dir.mkdir(exist_ok=True)
(assets_dir / "images").mkdir(exist_ok=True)
(assets_dir / "files").mkdir(exist_ok=True)
print(f"✓ 创建资源目录")
# 创建默认 README.md
readme_path = project_path / "README.md"
with open(readme_path, "w", encoding="utf-8") as f:
f.write(f"""# {PROJECT_INFO['name']}
{PROJECT_INFO['description']}
## 欢迎使用 NEX Docus!
这是您的项目首页,您可以在这里编写项目介绍、使用说明等内容。
### 快速开始
1. 在左侧目录树中创建文件夹和文档
2. 支持 Markdown 语法编写文档
3. 支持图片和附件上传
---
创建时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
""")
print(f"✓ 创建 README.md")
except Exception as e:
print(f"⚠️ 文件夹创建警告: {e}")
print(f" 请确保目录 {STORAGE_ROOT} 存在并有写入权限")
print(f" 或者修改 backend/.env 中的 STORAGE_ROOT 配置")
print("\n" + "="*60)
print("✅ 项目创建成功!")
print("="*60)
print(f"项目名称: {PROJECT_INFO['name']}")
print(f"项目ID: {project_id}")
print(f"存储路径: {project_path}")
print(f"UUID: {storage_key}")
print("="*60)
print("\n你现在可以:")
print("1. 启动后端服务: cd backend && python main.py")
print("2. 启动前端服务: cd frontend && npm run dev")
print("3. 登录系统 (admin / admin@123)")
print("4. 在项目中添加你的文档")
print()
except pymysql.Error as e:
print(f"❌ 数据库错误: {e}")
except Exception as e:
print(f"❌ 未知错误: {e}")
if __name__ == "__main__":
print("=" * 60)
print("创建 NEX Design 项目")
print("=" * 60)
print()
create_project()

View File

@ -1,23 +0,0 @@
CREATE TABLE IF NOT EXISTS `project_vectorization_task` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '任务ID',
`task_id` VARCHAR(64) NOT NULL COMMENT '任务唯一标识',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`user_id` BIGINT NOT NULL COMMENT '触发用户ID',
`task_type` VARCHAR(32) NOT NULL COMMENT '任务类型:incremental/full',
`status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态:pending/running/success/failed',
`total` INT NOT NULL DEFAULT 0 COMMENT '文件总数',
`processed` INT NOT NULL DEFAULT 0 COMMENT '处理成功数',
`skipped` INT NOT NULL DEFAULT 0 COMMENT '跳过数',
`failed` INT NOT NULL DEFAULT 0 COMMENT '失败数',
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
`started_at` DATETIME DEFAULT NULL COMMENT '开始时间',
`finished_at` DATETIME DEFAULT NULL COMMENT '完成时间',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY `uk_vector_task_id` (`task_id`),
INDEX `idx_vector_task_project_status` (`project_id`, `status`),
INDEX `idx_vector_task_user_id` (`user_id`),
INDEX `idx_vector_task_created_at` (`created_at`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目向量化任务表';

View File

@ -1,18 +0,0 @@
CREATE TABLE IF NOT EXISTS `share_links` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '分享ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`share_type` VARCHAR(20) NOT NULL COMMENT '分享类型: project/file',
`share_code` VARCHAR(64) NOT NULL COMMENT '公开分享码',
`file_path` VARCHAR(500) DEFAULT NULL COMMENT '文件路径,仅文件分享使用',
`access_pass` VARCHAR(100) DEFAULT NULL COMMENT '访问密码',
`created_by` BIGINT DEFAULT NULL COMMENT '创建人ID',
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY `uk_share_code` (`share_code`),
INDEX `idx_share_project` (`project_id`, `share_type`, `status`),
INDEX `idx_share_file` (`project_id`, `file_path`(255), `status`),
INDEX `idx_share_created_by` (`created_by`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享链接表';

View File

@ -1,95 +0,0 @@
"""
检查和修复角色的 is_system 字段
"""
import sys
import asyncio
from pathlib import Path
# 添加项目根目录到 Python 路径
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy import text
from app.core.database import async_session
async def check_and_fix_roles():
"""检查和修复角色的 is_system 字段"""
print("正在检查角色数据...")
async with async_session() as session:
# 查询所有角色
result = await session.execute(
text("SELECT id, role_name, role_code, is_system FROM roles ORDER BY id")
)
roles = result.fetchall()
print("\n当前角色列表:")
print("-" * 80)
print(f"{'ID':<5} {'角色名称':<20} {'角色编码':<20} {'是否系统角色':<15}")
print("-" * 80)
for role in roles:
is_system_text = "是" if role[3] == 1 else "否"
print(f"{role[0]:<5} {role[1]:<20} {role[2]:<20} {is_system_text:<15}")
print("-" * 80)
# 修复建议:只有 super_admin 应该是系统角色
print("\n开始修复角色 is_system 字段...")
# 将 super_admin 设置为系统角色
await session.execute(
text("UPDATE roles SET is_system = 1 WHERE role_code = 'super_admin'")
)
print("✓ 已将 super_admin 设置为系统角色")
# 将其他角色设置为非系统角色
await session.execute(
text("UPDATE roles SET is_system = 0 WHERE role_code != 'super_admin'")
)
print("✓ 已将其他角色设置为非系统角色")
await session.commit()
# 再次查询验证
result = await session.execute(
text("SELECT id, role_name, role_code, is_system FROM roles ORDER BY id")
)
roles = result.fetchall()
print("\n修复后的角色列表:")
print("-" * 80)
print(f"{'ID':<5} {'角色名称':<20} {'角色编码':<20} {'是否系统角色':<15}")
print("-" * 80)
for role in roles:
is_system_text = "是" if role[3] == 1 else "否"
print(f"{role[0]:<5} {role[1]:<20} {role[2]:<20} {is_system_text:<15}")
print("-" * 80)
print("\n✓ 角色数据修复完成!")
print("\n说明:")
print(" - super_admin (超级管理员): 系统角色,不允许修改权限")
print(" - admin (管理员): 非系统角色,可以修改权限")
print(" - user (普通用户): 非系统角色,可以修改权限")
async def main():
"""主函数"""
print("=" * 80)
print("检查和修复角色 is_system 字段")
print("=" * 80)
print()
try:
await check_and_fix_roles()
print()
print("=" * 80)
print("✓ 操作完成!现在可以在前端管理非系统角色的权限了")
print("=" * 80)
except Exception as e:
print(f"\n✗ 操作失败: {str(e)}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

View File

@ -1,340 +0,0 @@
-- NEX Docus 数据库初始化脚本
-- 创建数据库
CREATE DATABASE IF NOT EXISTS `nex_docus` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE `nex_docus`;
-- 1. 用户表
CREATE TABLE IF NOT EXISTS `users` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID',
`username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名(登录账号)',
`password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希(bcrypt)',
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称(显示名称)',
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL',
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
`is_superuser` TINYINT DEFAULT 0 COMMENT '是否超级管理员:0-否 1-是',
`last_login_at` DATETIME DEFAULT NULL COMMENT '最后登录时间',
`last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_username` (`username`),
INDEX `idx_email` (`email`),
INDEX `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 2. 角色表
CREATE TABLE IF NOT EXISTS `roles` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '角色ID',
`role_name` VARCHAR(50) NOT NULL UNIQUE COMMENT '角色名称',
`role_code` VARCHAR(50) NOT NULL UNIQUE COMMENT '角色编码',
`description` VARCHAR(255) DEFAULT NULL COMMENT '角色描述',
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
`is_system` TINYINT DEFAULT 0 COMMENT '是否系统角色:0-否 1-是',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_role_code` (`role_code`),
INDEX `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
-- 3. 用户角色关联表
CREATE TABLE IF NOT EXISTS `user_roles` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`role_id` BIGINT NOT NULL COMMENT '角色ID',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY `uk_user_role` (`user_id`, `role_id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_role_id` (`role_id`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';
-- 4. 系统菜单表
CREATE TABLE IF NOT EXISTS `system_menus` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '菜单ID',
`parent_id` BIGINT DEFAULT 0 COMMENT '父菜单ID',
`menu_name` VARCHAR(50) NOT NULL COMMENT '菜单名称',
`menu_code` VARCHAR(50) NOT NULL UNIQUE COMMENT '菜单编码',
`menu_type` TINYINT NOT NULL COMMENT '菜单类型:1-目录 2-菜单 3-按钮',
`path` VARCHAR(255) DEFAULT NULL COMMENT '路由路径',
`component` VARCHAR(255) DEFAULT NULL COMMENT '组件路径',
`icon` VARCHAR(100) DEFAULT NULL COMMENT '图标',
`sort_order` INT DEFAULT 0 COMMENT '排序号',
`visible` TINYINT DEFAULT 1 COMMENT '是否可见:0-隐藏 1-显示',
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
`permission` VARCHAR(100) DEFAULT NULL COMMENT '权限字符串',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_parent_id` (`parent_id`),
INDEX `idx_menu_code` (`menu_code`),
INDEX `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统菜单表';
-- 5. 角色菜单授权表
CREATE TABLE IF NOT EXISTS `role_menus` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID',
`role_id` BIGINT NOT NULL COMMENT '角色ID',
`menu_id` BIGINT NOT NULL COMMENT '菜单ID',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
UNIQUE KEY `uk_role_menu` (`role_id`, `menu_id`),
INDEX `idx_role_id` (`role_id`),
INDEX `idx_menu_id` (`menu_id`),
FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`menu_id`) REFERENCES `system_menus`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单授权表';
-- 6. 项目表
CREATE TABLE IF NOT EXISTS `projects` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '项目ID',
`name` VARCHAR(100) NOT NULL COMMENT '项目名称',
`description` VARCHAR(500) DEFAULT NULL COMMENT '项目描述',
`storage_key` CHAR(36) NOT NULL COMMENT '磁盘存储UUID',
`owner_id` BIGINT NOT NULL COMMENT '项目所有者ID',
`is_public` TINYINT DEFAULT 0 COMMENT '是否公开:0-私有 1-公开',
`is_template` TINYINT DEFAULT 0 COMMENT '是否模板项目:0-否 1-是',
`status` TINYINT DEFAULT 1 COMMENT '状态:0-归档 1-活跃',
`cover_image` VARCHAR(255) DEFAULT NULL COMMENT '封面图',
`sort_order` INT DEFAULT 0 COMMENT '排序号',
`visit_count` INT DEFAULT 0 COMMENT '访问次数',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY `uk_storage_key` (`storage_key`),
INDEX `idx_owner_id` (`owner_id`),
INDEX `idx_name` (`name`),
INDEX `idx_status` (`status`),
INDEX `idx_created_at` (`created_at`),
FOREIGN KEY (`owner_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目表';
-- 7. 项目成员表
CREATE TABLE IF NOT EXISTS `project_members` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '成员ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`role` ENUM('admin', 'editor', 'viewer') DEFAULT 'viewer' COMMENT '项目角色',
`invited_by` BIGINT DEFAULT NULL COMMENT '邀请人ID',
`joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间',
UNIQUE KEY `uk_project_user` (`project_id`, `user_id`),
INDEX `idx_project_id` (`project_id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_role` (`role`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`invited_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目成员表';
-- 8. 文档元数据表
CREATE TABLE IF NOT EXISTS `document_meta` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '元数据ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径',
`title` VARCHAR(200) DEFAULT NULL COMMENT '文档标题',
`tags` VARCHAR(500) DEFAULT NULL COMMENT '标签(JSON数组)',
`author_id` BIGINT DEFAULT NULL COMMENT '作者ID',
`word_count` INT DEFAULT 0 COMMENT '字数统计',
`view_count` INT DEFAULT 0 COMMENT '浏览次数',
`last_editor_id` BIGINT DEFAULT NULL COMMENT '最后编辑者ID',
`last_edited_at` DATETIME DEFAULT NULL COMMENT '最后编辑时间',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY `uk_project_path` (`project_id`, `file_path`(255)),
INDEX `idx_project_id` (`project_id`),
INDEX `idx_author_id` (`author_id`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON DELETE SET NULL,
FOREIGN KEY (`last_editor_id`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文档元数据表';
-- 9. 操作日志表
CREATE TABLE IF NOT EXISTS `operation_logs` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID',
`user_id` BIGINT DEFAULT NULL COMMENT '操作用户ID',
`username` VARCHAR(50) DEFAULT NULL COMMENT '用户名',
`operation_type` VARCHAR(50) NOT NULL COMMENT '操作类型',
`resource_type` VARCHAR(50) NOT NULL COMMENT '资源类型',
`resource_id` BIGINT DEFAULT NULL COMMENT '资源ID',
`detail` TEXT DEFAULT NULL COMMENT '操作详情(JSON)',
`ip_address` VARCHAR(50) DEFAULT NULL COMMENT 'IP地址',
`user_agent` VARCHAR(500) DEFAULT NULL COMMENT '用户代理',
`status` TINYINT DEFAULT 1 COMMENT '状态:0-失败 1-成功',
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
INDEX `idx_user_id` (`user_id`),
INDEX `idx_resource` (`resource_type`, `resource_id`),
INDEX `idx_created_at` (`created_at`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表';
-- 10. MCP Bot 凭证表
CREATE TABLE IF NOT EXISTS `mcp_bots` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'Bot credential ID',
`user_id` BIGINT NOT NULL COMMENT 'Owner user ID',
`bot_id` VARCHAR(64) NOT NULL COMMENT 'External MCP bot id',
`bot_secret` VARCHAR(255) NOT NULL COMMENT 'External MCP bot secret',
`status` TINYINT DEFAULT 1 COMMENT 'Status: 0-disabled 1-enabled',
`last_used_at` DATETIME DEFAULT NULL COMMENT 'Last successful MCP access time',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Created at',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Updated at',
UNIQUE KEY `uk_mcp_bots_user_id` (`user_id`),
UNIQUE KEY `uk_mcp_bots_bot_id` (`bot_id`),
INDEX `idx_mcp_bots_status` (`status`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MCP bot credentials';
-- 11. 分享链接表
CREATE TABLE IF NOT EXISTS `share_links` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '分享ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`share_type` VARCHAR(20) NOT NULL COMMENT '分享类型: project/file',
`share_code` VARCHAR(64) NOT NULL COMMENT '公开分享码',
`file_path` VARCHAR(500) DEFAULT NULL COMMENT '文件路径,仅文件分享使用',
`access_pass` VARCHAR(100) DEFAULT NULL COMMENT '访问密码',
`created_by` BIGINT DEFAULT NULL COMMENT '创建人ID',
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用 1-启用',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY `uk_share_code` (`share_code`),
INDEX `idx_share_project` (`project_id`, `share_type`, `status`),
INDEX `idx_share_file` (`project_id`, `file_path`(255), `status`),
INDEX `idx_share_created_by` (`created_by`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分享链接表';
-- 12. LLM 模型配置表
CREATE TABLE IF NOT EXISTS `llm_model_config` (
`config_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID',
`model_code` VARCHAR(128) NOT NULL COMMENT '模型编码',
`model_name` VARCHAR(255) NOT NULL COMMENT '模型名称',
`model_type` VARCHAR(32) NOT NULL DEFAULT 'chat' COMMENT '模型类型: chat/embedding',
`provider` VARCHAR(64) DEFAULT NULL COMMENT '模型提供方',
`endpoint_url` VARCHAR(512) DEFAULT NULL COMMENT '接口地址',
`api_key` VARCHAR(512) DEFAULT NULL COMMENT 'API Key',
`llm_model_name` VARCHAR(128) NOT NULL COMMENT '模型名称/部署名',
`llm_timeout` INT NOT NULL DEFAULT 120 COMMENT '超时时间(秒)',
`type_config` JSON NOT NULL COMMENT '模型类型差异参数',
`description` VARCHAR(500) DEFAULT NULL COMMENT '描述',
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
`is_default` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY `uk_model_code` (`model_code`),
INDEX `idx_model_code` (`model_code`),
INDEX `idx_model_type` (`model_type`),
INDEX `idx_is_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='LLM 模型配置表';
-- 13. 文档向量表
CREATE TABLE IF NOT EXISTS `document_vector` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '向量ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`file_path` VARCHAR(500) NOT NULL COMMENT '文件相对路径',
`content_hash` VARCHAR(64) DEFAULT NULL COMMENT '内容哈希值,用于判断文件是否变更',
`zvec_id` VARCHAR(256) DEFAULT NULL COMMENT 'ZVec返回的向量ID',
`zvec_response` TEXT DEFAULT NULL COMMENT 'ZVec完整响应JSON',
`status` VARCHAR(32) NOT NULL DEFAULT 'success' COMMENT '向量化状态:success/failed/pending',
`error_message` VARCHAR(500) DEFAULT NULL COMMENT '错误信息',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_project_file` (`project_id`, `file_path`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文档向量表';
-- 14. 知识库对话会话表
CREATE TABLE IF NOT EXISTS `chat_session` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '会话ID',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`llm_config_id` BIGINT NOT NULL COMMENT 'LLM配置ID',
`title` VARCHAR(255) NOT NULL COMMENT '会话标题',
`description` TEXT DEFAULT NULL COMMENT '会话描述',
`is_active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否激活',
`message_count` INT NOT NULL DEFAULT 0 COMMENT '消息数',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX `idx_project_id` (`project_id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_project_user` (`project_id`, `user_id`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`llm_config_id`) REFERENCES `llm_model_config`(`config_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库对话会话表';
-- 15. 知识库对话消息表
CREATE TABLE IF NOT EXISTS `chat_message` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID',
`session_id` BIGINT NOT NULL COMMENT '会话ID',
`role` VARCHAR(32) NOT NULL COMMENT '角色(user/assistant)',
`content` TEXT NOT NULL COMMENT '消息内容',
`status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '消息状态: pending/completed/interrupted/error',
`duration_ms` INT DEFAULT NULL COMMENT '生成耗时(毫秒)',
`thinking_log` TEXT DEFAULT NULL COMMENT '思考过程(JSON数组)',
`referenced_files` TEXT DEFAULT NULL COMMENT '参考文件(JSON数组)',
`tokens_used` INT DEFAULT NULL COMMENT '消耗的token数',
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已删除',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX `idx_session_id` (`session_id`),
FOREIGN KEY (`session_id`) REFERENCES `chat_session`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识库对话消息表';
-- 16. 项目向量化任务表
CREATE TABLE IF NOT EXISTS `project_vectorization_task` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '任务ID',
`task_id` VARCHAR(64) NOT NULL COMMENT '任务唯一标识',
`project_id` BIGINT NOT NULL COMMENT '项目ID',
`user_id` BIGINT NOT NULL COMMENT '触发用户ID',
`task_type` VARCHAR(32) NOT NULL COMMENT '任务类型:incremental/full',
`status` VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT '任务状态:pending/running/success/failed',
`total` INT NOT NULL DEFAULT 0 COMMENT '文件总数',
`processed` INT NOT NULL DEFAULT 0 COMMENT '处理成功数',
`skipped` INT NOT NULL DEFAULT 0 COMMENT '跳过数',
`failed` INT NOT NULL DEFAULT 0 COMMENT '失败数',
`error_message` TEXT DEFAULT NULL COMMENT '错误信息',
`started_at` DATETIME DEFAULT NULL COMMENT '开始时间',
`finished_at` DATETIME DEFAULT NULL COMMENT '完成时间',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY `uk_vector_task_id` (`task_id`),
INDEX `idx_vector_task_project_status` (`project_id`, `status`),
INDEX `idx_vector_task_user_id` (`user_id`),
INDEX `idx_vector_task_created_at` (`created_at`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目向量化任务表';
-- 插入初始角色数据
INSERT INTO `roles` (`role_name`, `role_code`, `description`, `is_system`) VALUES
('超级管理员', 'super_admin', '拥有系统所有权限', 1),
('项目管理员', 'project_admin', '可以创建和管理项目', 1),
('普通用户', 'user', '可以查看和编辑被授权的项目', 1);
-- 插入初始菜单数据
INSERT INTO `system_menus` (`id`, `parent_id`, `menu_name`, `menu_code`, `menu_type`, `path`, `icon`, `sort_order`, `permission`) VALUES
(1, 0, '项目管理', 'project', 1, NULL, 'FolderOutlined', 1, NULL),
(2, 1, '我的项目', 'projects:my', 2, '/projects', NULL, 1, 'project:view'),
(3, 1, '创建项目', 'project', 3, NULL, NULL, 2, 'project:create'),
(4, 1, '编辑项目', 'project:edit', 3, NULL, NULL, 3, 'project:edit'),
(5, 1, '删除项目', 'project:delete', 3, NULL, NULL, 4, 'project:delete'),
(10, 0, '知识库管理', 'knowledge', 1, NULL, 'FileTextOutlined', 2, NULL),
(11, 10, '我的知识库', 'knowledge:view', 2, '/chat', 'CommentOutlined', 1, 'knowledge:view'),
(12, 10, '编辑知识库', 'knowledge:edit', 3, NULL, NULL, 2, 'knowledge:edit'),
(13, 10, '删除知识库', 'knowledge:delete', 3, NULL, NULL, 3, 'knowledge:delete'),
(20, 0, '系统管理', 'system', 1, '/system', 'SettingOutlined', 3, NULL),
(21, 20, '用户管理', 'user_manage', 2, '/system/users', NULL, 1, 'system:user:view'),
(22, 20, '角色管理', 'role_manage', 2, '/system/roles', NULL, 2, 'system:role:view');
-- 创建默认管理员用户(密码: admin@123)
INSERT INTO `users` (`username`, `password_hash`, `nickname`, `is_superuser`, `status`) VALUES
('admin', '$2b$12$gQlTcKxyQKRYQtTT0Db.mexAsAYTJre9G8OSlzl3H3gMNTfXijiZy', '系统管理员', 1, 1);
-- 将管理员用户分配超级管理员角色
INSERT INTO `user_roles` (`user_id`, `role_id`) VALUES (1, 1);
-- 为超级管理员角色授权所有菜单
INSERT INTO `role_menus` (`role_id`, `menu_id`)
SELECT 1, id FROM `system_menus`;
-- 完成
SELECT '数据库初始化完成!' AS message;
SELECT CONCAT('默认管理员账号: admin, 密码: admin@123') AS credentials;

View File

@ -144,7 +144,7 @@ async def init_menus():
menu_name="知识库空间",
menu_code="knowledge",
menu_type=0,
path="/knowledge",
path="/chat",
icon="BookOutlined",
sort_order=4,
visible=1,
@ -220,7 +220,7 @@ async def init_menus():
menu_name="我的知识库",
menu_code="knowledge:my",
menu_type=1,
path="/knowledge",
path="/chat",
component="MyKnowledge",
icon="CommentOutlined",
sort_order=1,
@ -348,7 +348,7 @@ async def init_admin_user():
# 从环境变量获取管理员信息
admin_username = os.getenv("ADMIN_USERNAME", "admin")
admin_password = os.getenv("ADMIN_PASSWORD", "admin@123")
admin_email = os.getenv("ADMIN_EMAIL", "admin@unisspace.com")
admin_email = os.getenv("ADMIN_EMAIL", "admin@example.com")
admin_nickname = os.getenv("ADMIN_NICKNAME", "系统管理员")
async with async_session() as session:

View File

@ -1,94 +0,0 @@
"""
使用 Python 执行数据库初始化脚本
"""
import pymysql
import sys
from pathlib import Path
# 数据库配置
DB_CONFIG = {
'host': '10.100.51.51',
'port': 3306,
'user': 'root',
'password': 'Unis@123',
'charset': 'utf8mb4',
}
def execute_sql_file(sql_file_path):
"""执行 SQL 文件"""
try:
# 读取 SQL 文件
with open(sql_file_path, 'r', encoding='utf-8') as f:
sql_content = f.read()
# 连接数据库
print("正在连接数据库...")
connection = pymysql.connect(**DB_CONFIG)
try:
with connection.cursor() as cursor:
# 分割SQL语句(简单分割,按分号)
statements = []
current_statement = []
for line in sql_content.split('\n'):
# 跳过注释
line = line.strip()
if line.startswith('--') or not line:
continue
current_statement.append(line)
# 如果行以分号结尾,表示一条完整的语句
if line.endswith(';'):
statement = ' '.join(current_statement)
if statement.strip():
statements.append(statement)
current_statement = []
# 执行所有语句
print(f"共 {len(statements)} 条SQL语句...")
for i, statement in enumerate(statements, 1):
try:
cursor.execute(statement)
# 如果是SELECT语句,获取结果
if statement.strip().upper().startswith('SELECT'):
result = cursor.fetchall()
if result:
print(f" [{i}] {result}")
else:
print(f" [{i}] 执行成功")
except Exception as e:
print(f" [{i}] 执行失败: {e}")
print(f" SQL: {statement[:100]}...")
# 提交事务
connection.commit()
print("\n✅ 数据库初始化完成!")
finally:
connection.close()
except FileNotFoundError:
print(f"❌ SQL 文件不存在: {sql_file_path}")
sys.exit(1)
except pymysql.Error as e:
print(f"❌ 数据库错误: {e}")
sys.exit(1)
except Exception as e:
print(f"❌ 未知错误: {e}")
sys.exit(1)
if __name__ == "__main__":
script_dir = Path(__file__).parent
sql_file = script_dir / "init_database.sql"
print("=" * 60)
print("NEX Docus 数据库初始化")
print("=" * 60)
print(f"SQL 文件: {sql_file}")
print(f"数据库地址: {DB_CONFIG['host']}:{DB_CONFIG['port']}")
print("=" * 60)
print()
execute_sql_file(sql_file)

View File

@ -1,19 +0,0 @@
-- 迁移脚本:为 document_vector 表增加分块(chunk)支持
-- 背景:RAG 由「整篇文档单向量」升级为「按分块向量化」,
-- 一个文件可对应多条 chunk 记录。
--
-- 用法(已有数据库升级):
-- mysql -u <user> -p <db_name> < migrate_document_vector_add_chunks.sql
--
-- 注意:升级后建议对所有项目执行一次「全量重建」向量化,
-- 以将旧的整篇文档向量替换为分块向量(旧记录 chunk_index 默认为 0)。
ALTER TABLE `document_vector`
ADD COLUMN `chunk_index` INT NOT NULL DEFAULT 0
COMMENT '分块序号(0起),同一文件可有多个分块' AFTER `file_path`,
ADD COLUMN `chunk_text` TEXT DEFAULT NULL
COMMENT '分块首段文本,作为点击引用时的定位锚点' AFTER `chunk_index`;
-- 新增按 (project_id, file_path, chunk_index) 的复合索引,加速按分块查询/删除
ALTER TABLE `document_vector`
ADD INDEX `idx_project_file_chunk` (`project_id`, `file_path`, `chunk_index`);

View File

@ -1,8 +0,0 @@
-- 为已有数据库增加向量模型的分块参数。
-- 执行前请确认当前数据库已包含 llm_model_config 表。
ALTER TABLE `llm_model_config`
ADD COLUMN `chunk_size` INT NOT NULL DEFAULT 800
COMMENT '文档分块字符数(仅 embedding 类型)' AFTER `embedding_dimension`,
ADD COLUMN `chunk_overlap` INT NOT NULL DEFAULT 150
COMMENT '相邻分块重叠字符数(仅 embedding 类型)' AFTER `chunk_size`;

View File

@ -1,46 +0,0 @@
-- 将对话模型与向量模型的差异字段归并到 type_config JSON。
-- 适用于 MySQL 8.0;执行前应已完成 embedding_options 迁移。
ALTER TABLE `llm_model_config`
ADD COLUMN `type_config` JSON NULL
COMMENT '模型类型差异参数' AFTER `llm_timeout`;
UPDATE `llm_model_config`
SET `type_config` = CASE
WHEN `model_type` = 'embedding' THEN
CASE
WHEN `embedding_dimension` IS NULL THEN JSON_OBJECT(
'chunk_size', `chunk_size`,
'chunk_overlap', `chunk_overlap`
)
ELSE JSON_OBJECT(
'dimension', `embedding_dimension`,
'chunk_size', `chunk_size`,
'chunk_overlap', `chunk_overlap`
)
END
ELSE
CASE
WHEN `llm_system_prompt` IS NULL OR `llm_system_prompt` = '' THEN JSON_OBJECT(
'temperature', CAST(`llm_temperature` AS DOUBLE),
'top_p', CAST(`llm_top_p` AS DOUBLE),
'max_tokens', `llm_max_tokens`
)
ELSE JSON_OBJECT(
'temperature', CAST(`llm_temperature` AS DOUBLE),
'top_p', CAST(`llm_top_p` AS DOUBLE),
'max_tokens', `llm_max_tokens`,
'system_prompt', `llm_system_prompt`
)
END
END;
ALTER TABLE `llm_model_config`
MODIFY COLUMN `type_config` JSON NOT NULL COMMENT '模型类型差异参数',
DROP COLUMN `llm_temperature`,
DROP COLUMN `llm_top_p`,
DROP COLUMN `llm_max_tokens`,
DROP COLUMN `llm_system_prompt`,
DROP COLUMN `embedding_dimension`,
DROP COLUMN `chunk_size`,
DROP COLUMN `chunk_overlap`;

View File

@ -1,75 +0,0 @@
"""
更新系统管理菜单的路径
"""
import sys
import asyncio
from pathlib import Path
# 添加项目根目录到 Python 路径
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy import text
from app.core.database import async_session
async def update_menu_paths():
"""更新菜单路径"""
print("正在更新系统管理菜单路径...")
async with async_session() as session:
# 1. 更新角色权限管理菜单路径
result = await session.execute(
text("""
UPDATE system_menus
SET path = '/system/permissions', component = 'System/Permissions'
WHERE id = 14
""")
)
print(f"✓ 角色权限管理菜单路径已更新: /system/permissions")
# 2. 更新用户管理菜单路径
result = await session.execute(
text("""
UPDATE system_menus
SET path = '/system/users', component = 'System/Users'
WHERE id = 15
""")
)
print(f"✓ 用户管理菜单路径已更新: /system/users")
# 3. 更新角色管理菜单路径
result = await session.execute(
text("""
UPDATE system_menus
SET path = '/system/roles', component = 'System/Roles'
WHERE id = 16
""")
)
print(f"✓ 角色管理菜单路径已更新: /system/roles")
await session.commit()
print("\n✓ 所有菜单路径更新完成!")
async def main():
"""主函数"""
print("=" * 80)
print("更新系统管理菜单路径")
print("=" * 80)
print()
try:
await update_menu_paths()
print()
print("=" * 80)
print("✓ 操作完成!现在可以通过新路径访问系统管理菜单了")
print("=" * 80)
except Exception as e:
print(f"\n✗ 操作失败: {str(e)}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

View File

@ -5,6 +5,7 @@ from fastapi import HTTPException
from app.services.project_service import (
normalize_project_role,
require_project_read_access,
require_project_roles,
require_project_write_access,
)
@ -69,6 +70,28 @@ class ProjectPermissionsTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(context.exception.status_code, 403)
async def test_write_access_rejects_legacy_uppercase_viewer(self):
"""历史数据里 role 可能是大写 VIEWER,写权限校验必须先归一化再判断。"""
db = _RecordingDB([
self.project,
SimpleNamespace(role="VIEWER"),
])
with self.assertRaises(HTTPException) as context:
await require_project_write_access(db, 25, self.current_user)
self.assertEqual(context.exception.status_code, 403)
async def test_read_access_returns_normalized_role(self):
db = _RecordingDB([
self.project,
SimpleNamespace(role="VIEWER"),
])
project, role = await require_project_read_access(db, 25, self.current_user)
self.assertEqual(role, "viewer")
async def test_admin_only_permission_rejects_editor(self):
db = _RecordingDB([
self.project,

View File

@ -0,0 +1,55 @@
"""敏感配置启动自检回归用例。
背景:docker-compose 用 `${SECRET_KEY:-your-secret-key-change-me-in-production}` 之类的
占位默认值,运维忘记改 .env 时会带着公开已知的密钥上线;这里锁定告警行为。
"""
import os
import pytest
from app.core.config import Settings
BASE_ENV = {
"DB_HOST": "localhost",
"DB_USER": "u",
"DB_PASSWORD": "p",
"DB_NAME": "d",
"REDIS_HOST": "localhost",
"REDIS_PASSWORD": "r",
"SECRET_KEY": "x" * 48,
}
def make_settings(**overrides):
env = {**BASE_ENV, **{k: str(v) for k, v in overrides.items()}}
saved = {k: os.environ.get(k) for k in env}
os.environ.update(env)
try:
return Settings(_env_file=None)
finally:
for k, v in saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
def test_placeholder_secret_key_is_flagged():
s = make_settings(SECRET_KEY="your-secret-key-change-me-in-production")
assert any("SECRET_KEY" in w for w in s.security_warnings())
def test_short_secret_key_is_flagged():
s = make_settings(SECRET_KEY="abc")
assert any("SECRET_KEY" in w for w in s.security_warnings())
@pytest.mark.parametrize("password", ["User@123", "User@123456"])
def test_default_user_password_is_flagged(password):
s = make_settings(DEFAULT_USER_PASSWORD=password)
assert any("DEFAULT_USER_PASSWORD" in w for w in s.security_warnings())
def test_hardened_configuration_produces_no_warnings():
s = make_settings(SECRET_KEY="a" * 48, DEFAULT_USER_PASSWORD="R7#kQ!zs9dLp2Vt4")
assert s.security_warnings() == []

View File

@ -1,5 +1,3 @@
version: '3.8'
services:
# MySQL 数据库
mysql:
@ -74,6 +72,7 @@ services:
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-Admin@123456}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
- ADMIN_NICKNAME=${ADMIN_NICKNAME:-系统管理员}
- DEFAULT_USER_PASSWORD=${DEFAULT_USER_PASSWORD:-User@123456}
- TZ=Asia/Shanghai
volumes:
- ${STORAGE_PATH:-./storage}:/data/nex_docus_store
@ -96,8 +95,6 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
args:
- VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8000}
container_name: nex-docus-frontend
restart: unless-stopped
environment:

41
docs/README.md 100644
View File

@ -0,0 +1,41 @@
# 文档地图
> 本目录是 NexDocus 文档的**唯一入口**。仓库根目录只保留 `README.md`(项目总览),其余文档全部在此。
## 按角色导航
| 你是谁 / 想做什么 | 从这里开始 |
| --- | --- |
| 第一次跑起来 | [quickstart.md](quickstart.md) |
| 部署到服务器(Docker) | [deploy/README.md](deploy/README.md) |
| 查表结构、写 SQL、排查数据 | [database.md](database.md) |
| 给使用者介绍功能 | [manual/user-guide.md](manual/user-guide.md) |
| 写代码前先对齐设计 | [sdd/README.md](sdd/README.md) |
| 查某个版本改了什么 | [sdd/releases/](sdd/releases/) · [deploy/changelog.md](deploy/changelog.md) |
| 找运维/开发脚本 | [../scripts/README.md](../scripts/README.md) |
| 翻历史方案(已与现状不符) | [archive/README.md](archive/README.md) |
## 目录结构
```
docs/
├── README.md # 本文(文档地图)
├── quickstart.md # 开发环境快速上手
├── database.md # 数据库设计:18 张表 + 初始化/迁移链路
├── deploy/
│ ├── README.md # Docker Compose 部署、升级、备份恢复
│ └── changelog.md # 部署相关变更记录(端口、存储、脚本迁移等)
├── manual/
│ └── user-guide.md # 面向最终使用者的功能手册
├── sdd/ # 规格驱动开发(SDD)体系:愿景/架构/ADR/DV 规格/发布
└── archive/ # 历史文档归档,只作背景,不作为实现依据
```
## 文档维护约定
1. **新增文档只能落在 `docs/` 下**,根目录不再新增 `*.md`(`README.md` 除外)。
2. 文档内互链一律用**相对路径**,跨目录引用要能点击跳转;不要写"参见根目录 XXX.md"这类口头引用。
3. 涉及命令的段落必须与 `scripts/` 的实际行为一致(例如统一写 `./scripts/deploy.sh`,不是 `./deploy.sh`)。
4. **严禁在文档中写入真实环境的地址、账号、口令**。示例一律使用 `change_me` 之类的占位值。
5. 代码行为变更时,同一批改动里更新对应文档;做不到就在 `sdd/releases/` 的发布记录里登记待办。
6. 数据库结构以 `backend/app/models/` 为准,`database.md` 是其可读镜像;两者冲突时以模型为准并回头修文档。

View File

@ -0,0 +1,23 @@
# 历史归档(archive)
这里的文档**只用于追溯决策来源,不代表当前实现**。请勿据此开发、部署或排查问题。
| 文件 | 内容 | 为什么不再生效 |
| --- | --- | --- |
| [PROJECT.md](PROJECT.md) | 立项阶段的产品与技术方案(V1.0,2023-12):混合架构选型、FastAPI 决策、早期数据库与权限设计 | 表结构、模块划分、前端技术栈均已演进;现行架构看 [SDD](../sdd/README.md),表结构看 [数据库说明](../database.md) |
| [IMPLEMENTATION_PLAN.md](IMPLEMENTATION_PLAN.md) | 「远端合并 + ZVec 集成 + 知识库对话」三阶段实现计划(已执行完毕) | 是一次性施工计划,任务已落地;后续版本规划看 [发布记录](../sdd/releases/README.md) 与 [路线图](../sdd/product/roadmap.md) |
## 现行文档入口
- 使用者:[使用手册](../manual/user-guide.md)
- 本地开发:[快速开始](../quickstart.md)
- 部署运维:[部署指南](../deploy/README.md)、[配置变更日志](../deploy/changelog.md)
- 数据模型:[数据库说明](../database.md)
- 规格/架构/决策:[SDD 目录](../sdd/README.md)
- 全部文档索引:[docs/README.md](../README.md)
## 归档规则
1. 文档一旦与代码不符且不再维护,就 `git mv` 到本目录,并在上表登记原因,**不要直接删除**(保留决策来源)。
2. 归档文件**不再修改内容**;需要更新信息时,在现行文档中重写。
3. 归档文档中的外链可能已失效,反向链接(现行文档指向归档)应尽量避免。

415
docs/database.md 100644
View File

@ -0,0 +1,415 @@
# 数据库设计
> 权威来源是 `backend/app/models/`:本文是它的可读镜像,两者冲突时**以模型为准并回改本文**。
> 结构变更流程见 [quickstart.md §9](quickstart.md#9-数据库结构与迁移)。
- 引擎:MySQL 8.0 / InnoDB / `utf8mb4` / `utf8mb4_unicode_ci`
- 表数量:**18**
- 时间列:`created_at` / `updated_at` 由各模型的 `default=now` / `onupdate=now` 维护(DATETIME,无时区,容器时区固定 `Asia/Shanghai`)
- 主键:除 `llm_model_config.config_id` 外,其余表主键均为自增 `id`
- ⚠️ **文档正文不在数据库里**:Markdown 正文存在文件系统(`STORAGE_ROOT/projects/<storage_key>/…`),数据库只存权限、元数据与索引(见 [sdd ADR-0001](sdd/architecture/decisions/ADR-0001-hybrid-storage.md)、[ADR-0002](sdd/architecture/decisions/ADR-0002-uuid-storage-key.md))
## 表清单
| 分组 | 表 | 用途 | 主要写入方 |
| --- | --- | --- | --- |
| 身份权限 | `users` | 账号、状态、超管标记、登录痕迹 | 注册、用户管理、登录 |
| 身份权限 | `roles` | 角色(super_admin / admin / user + 自定义) | 角色管理、`init_db.py` 种子 |
| 身份权限 | `user_roles` | 用户 ↔ 角色 | 用户管理 |
| 身份权限 | `system_menus` | 菜单 **与** 按钮级权限点(`menu_type` 区分) | `init_db.py` 种子、权限管理 |
| 身份权限 | `role_menus` | 角色 ↔ 菜单/权限点 | 权限管理 |
| 项目文档 | `projects` | 项目主表,`storage_key` 是磁盘目录名 | 项目管理 |
| 项目文档 | `project_members` | 项目成员与项目内角色 | 成员管理 |
| 项目文档 | `document_meta` | 文档标题/标签/字数/浏览与编辑痕迹(可选表,正文仍在文件) | 保存文件、浏览统计 |
| 项目文档 | `project_git_repos` | 项目绑定的 Git 仓库(含令牌) | Git 同步设置 |
| 项目文档 | `share_links` | 项目/文件分享码与访问密码 | 分享管理 |
| AI 知识库 | `llm_model_config` | Chat / Embedding 模型配置 | 模型配置 |
| AI 知识库 | `chat_session` | 对话会话(按项目 + 用户 + 模型) | 对话 |
| AI 知识库 | `chat_message` | 消息、状态、耗时、思考过程、引用文件 | 对话 |
| AI 知识库 | `document_vector` | 文档分块 → 向量库映射(ZVec) | 向量化 |
| AI 知识库 | `project_vectorization_task` | 全量/增量向量化任务与进度 | 向量化 |
| 通知审计 | `notifications` | 站内通知 | 通知服务 |
| 通知审计 | `operation_logs` | 操作审计(谁、何时、对什么、结果) | `log_service`(各 API 埋点) |
| 通知审计 | `mcp_bots` | MCP Bot 的 `bot_id/secret` 凭证 | MCP 凭证管理 |
## 关系(逻辑外键)
数据库只在 `notifications.user_id` 与 `project_git_repos.project_id` 上声明了真正的 FOREIGN KEY,其余是**逻辑外键**(不建约束,便于分库与批量清理):
```
users 1─N user_roles N─1 roles 1─N role_menus N─1 system_menus
users 1─N projects(owner_id) 1─N project_members N─1 users
projects 1─N document_meta / share_links / project_git_repos
/ document_vector / project_vectorization_task / chat_session
chat_session 1─N chat_message
projects 1─N chat_session(同时 chat_session.user_id → users)
```
删除项目时由服务层负责级联清理文件目录与相关索引行;不要指望数据库级联。
## 初始化与迁移
| 阶段 | 代码 | 行为 |
| --- | --- | --- |
| 建表 | `backend/scripts/init_db.py` → `Base.metadata.create_all` | 只创建缺失表;表结构来自 `app/models/`。**模型必须在 `app/models/__init__.py` 中注册**,否则该表不会出现在新库里 |
| 种子 | 同上 | 角色(super_admin/admin/user)、系统菜单与权限点、管理员账号;按 id 与 `(parent_id, menu_name)` 去重,幂等增量 |
| 补列 | `app/core/migrations.py::migrate_schema()` | 幂等 `ALTER TABLE ADD COLUMN`(先查 `information_schema`)。当前覆盖:`chat_message.status/duration_ms/thinking_log`、`project_git_repos.sync_path` |
| 未使用 | Alembic | 依赖里有 `alembic`,但项目未启用版本化迁移脚本 |
Docker 部署时后端容器启动命令是 `python scripts/init_db.py && uvicorn main:app …`,因此**新环境无需手工执行 SQL**。
## 已知数据差异与运维建议
1. **`project_members.role` 大小写混杂**:历史数据(早期 `init_database.sql` 建的库)多为 `ADMIN/EDITOR/VIEWER`,新写入是 `admin/editor/viewer`。读取路径统一经过 `app/services/project_service.normalize_project_role()` 归一化,因此行为正确;如需彻底清洗,可执行:
```sql
UPDATE project_members SET role = LOWER(role) WHERE BINARY role <> LOWER(role);
```
2. **`share_links.access_pass` / `project_git_repos.token` 为明文存储**:属已知技术债(见发布报告 P2)。上线前应限制库账号的远程访问并纳入备份加密范围。
3. **`document_vector` 无数据库唯一约束**:靠 `idx_project_file_chunk(project_id, file_path, chunk_index)` 普通索引查询,重复向量化由服务层用 `content_hash` 判定,不在库里去重。
4. **`llm_model_config.api_key` 明文**:同上,接口返回时会脱敏,但库里是原文。
5. **备份**:文件系统(`STORAGE_PATH`)与数据库必须一起备份,缺一个都无法还原,命令见 [deploy/README.md](deploy/README.md)。
```sql
-- 常用排查
SELECT id, name, storage_key, owner_id, is_public FROM projects WHERE id = ?; -- storage_key 即磁盘目录名
SELECT status, COUNT(*) FROM document_vector WHERE project_id = ? GROUP BY status;
SELECT task_id, task_type, status, total, processed, failed FROM project_vectorization_task
WHERE project_id = ? ORDER BY created_at DESC LIMIT 5;
SELECT role, COUNT(*) FROM project_members WHERE project_id = ? GROUP BY role;
```
---
## 身份与权限
### `users` — 用户
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | 用户ID |
| `username` | VARCHAR(50) | UNIQUE · NOT NULL | 用户名 |
| `password_hash` | VARCHAR(255) | NOT NULL | 密码哈希 |
| `nickname` | VARCHAR(50) | — | 昵称 |
| `email` | VARCHAR(100) | — | 邮箱 |
| `phone` | VARCHAR(20) | — | 手机号 |
| `avatar` | VARCHAR(255) | — | 头像URL |
| `status` | SMALLINT | 默认 `1` | 状态:0-禁用 1-启用 |
| `is_superuser` | SMALLINT | 默认 `0` | 是否超级管理员:0-否 1-是 |
| `last_login_at` | DATETIME | — | 最后登录时间 |
| `last_login_ip` | VARCHAR(50) | — | 最后登录IP |
| `created_at` | DATETIME | — | 创建时间 |
| `updated_at` | DATETIME | — | 更新时间 |
索引:`ix_users_email`(email);`ix_users_status`(status);`ix_users_username`(username) UNIQUE
### `roles` — 角色
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | 角色ID |
| `role_name` | VARCHAR(50) | UNIQUE · NOT NULL | 角色名称 |
| `role_code` | VARCHAR(50) | UNIQUE · NOT NULL | 角色编码 |
| `description` | VARCHAR(255) | — | 角色描述 |
| `status` | SMALLINT | 默认 `1` | 状态:0-禁用 1-启用 |
| `is_system` | SMALLINT | 默认 `0` | 是否系统角色:0-否 1-是 |
| `created_at` | DATETIME | — | 创建时间 |
| `updated_at` | DATETIME | — | 更新时间 |
索引:`ix_roles_role_code`(role_code) UNIQUE;`ix_roles_status`(status)
### `user_roles` — 用户-角色关联
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | 关联ID |
| `user_id` | BIGINT | NOT NULL | 用户ID |
| `role_id` | BIGINT | NOT NULL | 角色ID |
| `created_at` | DATETIME | — | 创建时间 |
索引:`ix_user_roles_role_id`(role_id);`ix_user_roles_user_id`(user_id)
### `system_menus` — 系统菜单/权限点
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | 菜单ID |
| `parent_id` | BIGINT | 默认 `0` | 父菜单ID(0表示根菜单) |
| `menu_name` | VARCHAR(50) | NOT NULL | 菜单名称 |
| `menu_code` | VARCHAR(50) | UNIQUE · NOT NULL | 菜单编码 |
| `menu_type` | SMALLINT | NOT NULL | 菜单类型:1-目录 2-菜单 3-按钮/权限点 |
| `path` | VARCHAR(255) | — | 路由路径 |
| `component` | VARCHAR(255) | — | 组件路径 |
| `icon` | VARCHAR(100) | — | 图标 |
| `sort_order` | INTEGER | 默认 `0` | 排序号 |
| `visible` | SMALLINT | 默认 `1` | 是否可见:0-隐藏 1-显示 |
| `status` | SMALLINT | 默认 `1` | 状态:0-禁用 1-启用 |
| `permission` | VARCHAR(100) | — | 权限字符串 |
| `created_at` | DATETIME | — | 创建时间 |
| `updated_at` | DATETIME | — | 更新时间 |
索引:`ix_system_menus_menu_code`(menu_code) UNIQUE;`ix_system_menus_status`(status)
### `role_menus` — 角色-菜单授权
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | 关联ID |
| `role_id` | BIGINT | NOT NULL | 角色ID |
| `menu_id` | BIGINT | NOT NULL | 菜单ID |
| `created_at` | DATETIME | — | 创建时间 |
索引:`ix_role_menus_menu_id`(menu_id);`ix_role_menus_role_id`(role_id)
## 项目与文档
### `projects` — 项目
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | 项目ID |
| `name` | VARCHAR(100) | NOT NULL | 项目名称 |
| `description` | VARCHAR(500) | — | 项目描述 |
| `storage_key` | VARCHAR(36) | UNIQUE · NOT NULL | 磁盘存储UUID |
| `owner_id` | BIGINT | NOT NULL | 项目所有者ID |
| `is_public` | SMALLINT | 默认 `0` | 是否公开:0-私有 1-公开 |
| `is_template` | SMALLINT | 默认 `0` | 是否模板项目:0-否 1-是 |
| `status` | SMALLINT | 默认 `1` | 状态:0-归档 1-活跃 |
| `cover_image` | VARCHAR(255) | — | 封面图 |
| `sort_order` | INTEGER | 默认 `0` | 排序号 |
| `visit_count` | INTEGER | 默认 `0` | 访问次数 |
| `access_pass` | VARCHAR(100) | — | 访问密码(用于分享链接) |
| `created_at` | DATETIME | — | 创建时间 |
| `updated_at` | DATETIME | — | 更新时间 |
索引:`ix_projects_created_at`(created_at);`ix_projects_name`(name);`ix_projects_owner_id`(owner_id);`ix_projects_status`(status)
### `project_members` — 项目成员
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | 成员ID |
| `project_id` | BIGINT | NOT NULL | 项目ID |
| `user_id` | BIGINT | NOT NULL | 用户ID |
| `role` | VARCHAR(20) | 默认 `viewer` | 项目角色: admin/editor/viewer |
| `invited_by` | BIGINT | — | 邀请人ID |
| `joined_at` | DATETIME | — | 加入时间 |
索引:`ix_project_members_project_id`(project_id);`ix_project_members_role`(role);`ix_project_members_user_id`(user_id)
### `document_meta` — 文档元数据
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | 元数据ID |
| `project_id` | BIGINT | NOT NULL | 项目ID |
| `file_path` | VARCHAR(500) | NOT NULL | 文件相对路径 |
| `title` | VARCHAR(200) | — | 文档标题 |
| `tags` | VARCHAR(500) | — | 标签(JSON数组) |
| `author_id` | BIGINT | — | 作者ID |
| `word_count` | INTEGER | 默认 `0` | 字数统计 |
| `view_count` | INTEGER | 默认 `0` | 浏览次数 |
| `last_editor_id` | BIGINT | — | 最后编辑者ID |
| `last_edited_at` | DATETIME | — | 最后编辑时间 |
| `created_at` | DATETIME | — | 创建时间 |
| `updated_at` | DATETIME | — | 更新时间 |
索引:`ix_document_meta_author_id`(author_id);`ix_document_meta_project_id`(project_id)
### `project_git_repos` — 项目绑定的 Git 仓库
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | ID |
| `project_id` | BIGINT | FK → projects.id · NOT NULL | 项目ID |
| `name` | VARCHAR(50) | NOT NULL | 仓库别名 |
| `repo_url` | VARCHAR(255) | NOT NULL | Git仓库地址 |
| `branch` | VARCHAR(50) | 默认 `main` | Git分支 |
| `username` | VARCHAR(100) | — | Git用户名 |
| `token` | VARCHAR(255) | — | Git访问令牌/密码 |
| `is_default` | SMALLINT | 默认 `0` | 是否默认仓库 |
| `sync_path` | VARCHAR(255) | — | 同步目录(空=整个仓库) |
| `created_at` | DATETIME | — | 创建时间 |
| `updated_at` | DATETIME | — | 更新时间 |
索引:`ix_project_git_repos_project_id`(project_id)
### `share_links` — 分享链接
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | 分享ID |
| `project_id` | BIGINT | NOT NULL | 项目ID |
| `share_type` | VARCHAR(20) | NOT NULL | 分享类型: project/file |
| `share_code` | VARCHAR(64) | UNIQUE · NOT NULL | 公开分享码 |
| `file_path` | VARCHAR(500) | — | 文件路径,仅文件分享使用 |
| `access_pass` | VARCHAR(100) | — | 访问密码 |
| `created_by` | BIGINT | — | 创建人ID |
| `status` | SMALLINT | 默认 `1` | 状态:0-禁用 1-启用 |
| `created_at` | DATETIME | — | 创建时间 |
| `updated_at` | DATETIME | — | 更新时间 |
索引:`ix_share_links_created_by`(created_by);`ix_share_links_project_id`(project_id);`ix_share_links_share_code`(share_code) UNIQUE;`ix_share_links_share_type`(share_type);`ix_share_links_status`(status)
## AI 知识库
### `llm_model_config` — LLM 模型配置
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `config_id` | BIGINT | PK | 配置ID |
| `model_code` | VARCHAR(128) | UNIQUE · NOT NULL | 模型编码 |
| `model_name` | VARCHAR(255) | NOT NULL | 模型名称 |
| `model_type` | VARCHAR(32) | NOT NULL · 默认 `chat` | 模型类型: chat/embedding |
| `provider` | VARCHAR(64) | — | 模型提供方 |
| `endpoint_url` | VARCHAR(512) | — | 接口地址 |
| `api_key` | VARCHAR(512) | — | API Key |
| `llm_model_name` | VARCHAR(128) | NOT NULL | 模型名称/部署名 |
| `llm_timeout` | INTEGER | NOT NULL · 默认 `120` | 超时时间(秒) |
| `type_config` | JSON | NOT NULL · 默认 `服务端函数` | 模型类型差异参数 |
| `description` | VARCHAR(500) | — | 描述 |
| `is_active` | BOOLEAN | NOT NULL · 默认 `True` | 是否启用 |
| `is_default` | BOOLEAN | NOT NULL · 默认 `False` | 是否默认 |
| `created_at` | DATETIME | — | 创建时间 |
| `updated_at` | DATETIME | — | 更新时间 |
索引:`ix_llm_model_config_is_active`(is_active);`ix_llm_model_config_model_code`(model_code) UNIQUE;`ix_llm_model_config_model_type`(model_type)
### `chat_session` — 对话会话
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | 会话ID |
| `project_id` | BIGINT | NOT NULL | 项目ID |
| `user_id` | BIGINT | NOT NULL | 用户ID |
| `llm_config_id` | BIGINT | NOT NULL | LLM配置ID |
| `title` | VARCHAR(255) | NOT NULL | 会话标题 |
| `description` | TEXT | — | 会话描述 |
| `is_active` | BOOLEAN | NOT NULL · 默认 `True` | 是否激活 |
| `message_count` | INTEGER | NOT NULL · 默认 `0` | 消息数 |
| `created_at` | DATETIME | — | 创建时间 |
| `updated_at` | DATETIME | — | 更新时间 |
索引:`ix_chat_session_project_id`(project_id);`ix_chat_session_user_id`(user_id)
### `chat_message` — 对话消息
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | 消息ID |
| `session_id` | BIGINT | NOT NULL | 会话ID |
| `role` | VARCHAR(32) | NOT NULL | 角色(user/assistant) |
| `content` | TEXT | NOT NULL | 消息内容 |
| `status` | VARCHAR(32) | NOT NULL · 默认 `pending` | 消息状态: pending/completed/interrupted/error |
| `duration_ms` | INTEGER | — | 生成耗时(毫秒) |
| `thinking_log` | TEXT | — | 思考过程(JSON数组) |
| `referenced_files` | TEXT | — | 参考文件(JSON数组) |
| `tokens_used` | INTEGER | — | 消耗的token数 |
| `is_deleted` | BOOLEAN | NOT NULL · 默认 `False` | 是否已删除 |
| `created_at` | DATETIME | — | 创建时间 |
索引:`ix_chat_message_session_id`(session_id)
### `document_vector` — 文档向量分块
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | 向量ID |
| `project_id` | BIGINT | NOT NULL | 项目ID |
| `file_path` | VARCHAR(500) | NOT NULL | 文件相对路径 |
| `chunk_index` | INTEGER | NOT NULL · 默认 `0` | 分块序号(0起),同一文件可有多个分块 |
| `chunk_text` | TEXT | — | 分块首段文本,作为点击引用时的定位锚点 |
| `content_hash` | VARCHAR(64) | — | 整个文件内容哈希值,用于判断文件是否变更 |
| `zvec_id` | VARCHAR(256) | — | ZVec返回的向量ID(每个分块独立) |
| `zvec_response` | TEXT | — | ZVec完整响应JSON |
| `status` | VARCHAR(32) | NOT NULL · 默认 `success` | 向量化状态:success/failed/pending |
| `error_message` | VARCHAR(500) | — | 错误信息 |
| `created_at` | DATETIME | — | 创建时间 |
| `updated_at` | DATETIME | — | 更新时间 |
索引:`idx_project_file_chunk`(project_id, file_path, chunk_index);`idx_project_file`(project_id, file_path);`ix_document_vector_project_id`(project_id)
### `project_vectorization_task` — 项目向量化任务
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | 任务ID |
| `task_id` | VARCHAR(64) | UNIQUE · NOT NULL | 任务唯一标识 |
| `project_id` | BIGINT | NOT NULL | 项目ID |
| `user_id` | BIGINT | NOT NULL | 触发用户ID |
| `task_type` | VARCHAR(32) | NOT NULL | 任务类型:incremental/full |
| `status` | VARCHAR(32) | NOT NULL · 默认 `pending` | 任务状态:pending/running/success/failed |
| `total` | INTEGER | NOT NULL · 默认 `0` | 文件总数 |
| `processed` | INTEGER | NOT NULL · 默认 `0` | 处理成功数 |
| `skipped` | INTEGER | NOT NULL · 默认 `0` | 跳过数 |
| `failed` | INTEGER | NOT NULL · 默认 `0` | 失败数 |
| `error_message` | TEXT | — | 错误信息 |
| `started_at` | DATETIME | — | 开始时间 |
| `finished_at` | DATETIME | — | 完成时间 |
| `created_at` | DATETIME | — | 创建时间 |
| `updated_at` | DATETIME | — | 更新时间 |
索引:`idx_vector_task_created_at`(created_at);`idx_vector_task_project_status`(project_id, status);`ix_project_vectorization_task_project_id`(project_id);`ix_project_vectorization_task_status`(status);`ix_project_vectorization_task_task_id`(task_id) UNIQUE;`ix_project_vectorization_task_user_id`(user_id)
## 通知与审计
### `notifications` — 站内通知
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | 通知ID |
| `user_id` | BIGINT | FK → users.id · NOT NULL | 接收用户ID |
| `type` | VARCHAR(20) | 默认 `info` | 类型:info, success, warning, error |
| `category` | VARCHAR(50) | 默认 `system` | 分类:system, project, collaboration |
| `title` | VARCHAR(200) | NOT NULL | 标题 |
| `content` | TEXT | — | 内容 |
| `link` | VARCHAR(255) | — | 跳转链接 |
| `is_read` | SMALLINT | 默认 `0` | 是否已读:0-未读 1-已读 |
| `created_at` | DATETIME | — | 创建时间 |
| `read_at` | DATETIME | — | 阅读时间 |
索引:`ix_notifications_user_id`(user_id)
### `operation_logs` — 操作日志
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | 日志ID |
| `user_id` | BIGINT | — | 操作用户ID |
| `username` | VARCHAR(50) | — | 用户名 |
| `operation_type` | VARCHAR(50) | NOT NULL | 操作类型 |
| `resource_type` | VARCHAR(50) | NOT NULL | 资源类型 |
| `resource_id` | BIGINT | — | 资源ID |
| `detail` | TEXT | — | 操作详情(JSON) |
| `ip_address` | VARCHAR(50) | — | IP地址 |
| `user_agent` | VARCHAR(500) | — | 用户代理 |
| `status` | SMALLINT | 默认 `1` | 状态:0-失败 1-成功 |
| `error_message` | TEXT | — | 错误信息 |
| `created_at` | DATETIME | — | 操作时间 |
索引:`ix_operation_logs_created_at`(created_at);`ix_operation_logs_resource_id`(resource_id);`ix_operation_logs_resource_type`(resource_type);`ix_operation_logs_user_id`(user_id)
### `mcp_bots` — MCP Bot 凭证
| 字段 | 类型 | 约束/默认 | 说明 |
| --- | --- | --- | --- |
| `id` | BIGINT | PK | Bot credential ID |
| `user_id` | BIGINT | UNIQUE · NOT NULL | Owner user ID |
| `bot_id` | VARCHAR(64) | UNIQUE · NOT NULL | External MCP bot id |
| `bot_secret` | VARCHAR(255) | NOT NULL | External MCP bot secret |
| `status` | SMALLINT | 默认 `1` | Status: 0-disabled 1-enabled |
| `last_used_at` | DATETIME | — | Last successful MCP access time |
| `created_at` | DATETIME | — | Created at |
| `updated_at` | DATETIME | — | Updated at |
索引:`ix_mcp_bots_bot_id`(bot_id) UNIQUE;`ix_mcp_bots_status`(status);`ix_mcp_bots_user_id`(user_id) UNIQUE
---
## 变更记录
| 日期 | 变更 |
| --- | --- |
| 2026-09-30 | 按 v1.0.0 代码重写:补齐 `notifications`、`mcp_bots`、`project_git_repos`、`chat_*`、`document_vector`、`project_vectorization_task` 等 10 张此前未记录的表;删除已废弃的 `init_database.sql` 相关描述 |

View File

@ -0,0 +1,177 @@
# 部署指南(Docker Compose)
> 本地开发请用 [`./scripts/start.sh`](../quickstart.md),不要用本文流程。
> 本文所有命令都在**仓库根目录**执行;部署脚本已迁到 `scripts/`,写法固定为 `./scripts/deploy.sh …`。
## 1. 运行拓扑
`docker-compose.yml` 定义 4 个服务,容器名前缀 `nex-docus-`,基础镜像走华为云 SWR 镜像站(国内直连可用):
| 服务 | 容器 | 端口(宿主机→容器) | 说明 |
| --- | --- | --- | --- |
| `mysql` | `nex-docus-mysql` | `${MYSQL_PORT:-3306}` → 3306 | MySQL 8.0,`utf8mb4` / `utf8mb4_unicode_ci`,数据在 `${STORAGE_PATH}/mysql` |
| `redis` | `nex-docus-redis` | `${REDIS_PORT:-6379}` → 6379 | Redis 7,`requirepass` + AOF,数据在 `${STORAGE_PATH}/redis` |
| `backend` | `nex-docus-backend` | `${BACKEND_PORT:-8000}` → 8000 | 由 `backend/Dockerfile` 构建;启动命令 `python scripts/init_db.py && uvicorn main:app …`,即**建表/迁移/种子全自动** |
| `frontend` | `nex-docus-frontend` | `${FRONTEND_PORT:-8080}` → 80 | 由 `frontend/Dockerfile` 构建(node 构建 + nginx 托管),nginx 反代 `/api/`、`/mcp` 到后端 |
- 网络:`nex-docus-network`;后端在容器内的上游主机名是 `backend`,前端 nginx 依赖该名字。
- **文件存储**:宿主机 `${STORAGE_PATH}` 挂到后端 `/data/nex_docus_store`(后端配置里的 `STORAGE_ROOT`)。文档正文、向量索引、搜索索引、PDF 缓存都在这里。
- ⚠️ compose 里的相对路径按**执行 compose 的当前目录**解析,建议 `.env` 中把 `STORAGE_PATH` 写成绝对路径(如 `/data/nexdocus/storage`),避免换目录执行时数据"凭空消失"。
## 2. 前置要求
- Docker 20.10+,Docker Compose v2(`docker compose`;脚本也兼容老 `docker-compose`)
- 磁盘 ≥ 15 GB 空闲(后端镜像要装 PyTorch / weasyprint / zvec,镜像本身 + 向量索引 + 文档存储增长都吃磁盘)
- 内存 ≥ 4 GB(若启用本地 Embedding `sentence-transformers`,建议 8 GB)
- 出站网络可达所选 LLM / Embedding 服务
## 3. 配置 `.env`
```bash
cp .env.example .env
$EDITOR .env
```
| 键 | 默认值 | 说明 |
| --- | --- | --- |
| `MYSQL_ROOT_PASSWORD` | `root_password_change_me` | **必改** |
| `DB_NAME` / `DB_USER` / `DB_PASSWORD` | `nex_docus` / `nexdocus` / `password_change_me` | 应用库与账号;**必改密码** |
| `MYSQL_PORT` | `3306` | 宿主机端口;不需要外部访问时建议改成 `127.0.0.1:3306` 或干脆不映射 |
| `REDIS_PASSWORD` / `REDIS_PORT` / `REDIS_DB` | `redis_password_change_me` / `6379` / `8` | **必改密码** |
| `SECRET_KEY` | 占位串 | **必改**:`openssl rand -hex 32`。改了会让已登录用户全部失效 |
| `DEBUG` | `false` | 保持 false(true 会打印全量 SQL) |
| `BACKEND_PORT` / `FRONTEND_PORT` | `8000` / `8080` | 对外端口;用户访问的是 `FRONTEND_PORT` |
| `STORAGE_PATH` | `./storage` | 持久化根目录,**建议绝对路径**;MySQL/Redis 数据也放这里 |
| `CHUNK_SIZE` / `CHUNK_OVERLAP` | `800` / `150` | RAG 分块策略,改动后需重新向量化才生效 |
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL` / `ADMIN_NICKNAME` | `admin` / `Admin@123456` / `admin@example.com` / `系统管理员` | **只在第一次建库时生效**,账号已存在则不会改密码 |
| `DEFAULT_USER_PASSWORD` | `User@123456` | 管理员在「系统管理 → 用户管理」新建用户时的初始密码;**建议改随机值**,否则后端启动会打 `[安全自检]` 告警 |
| `DISABLE_SSL_VERIFY` | `false` | 仅自签名证书的内部模型服务才开 |
| `ZVEC_DATA_DIR` | 空 | 留空即 `${STORAGE_ROOT}/vector_index` |
| `ZVEC_EMBEDDING_DIM` | `1536` | 必须与选定的 embedding 模型维度一致,换模型要重建索引 |
> 前端**没有** API 地址配置项:容器内 nginx 固定把 `/api/` 反代到 `backend:8000`。跨域直连后端需自行改 `frontend/nginx.conf`。
> 这套 `.env`(部署用)与 `backend/.env`(开发用,键名不同)是两套配置,容器里只认前者。
## 4. 首次部署
```bash
./scripts/deploy.sh init
```
依次执行:`.env` 检查(没有就从 `.env.example` 生成并中止,等你填)→ Docker 检查 → `pull` → `build --no-cache` → 起 mysql/redis → 等 15s → `run --rm backend python scripts/init_db.py` → `up -d` → 打印访问信息。
等价的手工流程:
```bash
docker compose pull
docker compose build
docker compose up -d mysql redis
until docker compose exec -T mysql mysqladmin ping -hlocalhost -uroot -p"$MYSQL_ROOT_PASSWORD" --silent; do sleep 2; done
docker compose run --rm backend python scripts/init_db.py
docker compose up -d
docker compose ps
```
验证:
```bash
curl -fsS http://127.0.0.1:8000/health # {"status":"healthy"}
docker compose ps # 4 个服务 healthy/running
open http://<服务器IP>:8080 # 登录页;默认 admin / .env 里的 ADMIN_PASSWORD
```
## 5. 日常运维
| 命令 | 作用 |
| --- | --- |
| `./scripts/deploy.sh start` / `stop` / `restart` | 起停服务 |
| `./scripts/deploy.sh status` | 容器状态 |
| `./scripts/deploy.sh logs` | 跟踪全部日志 |
| `./scripts/deploy.sh logs backend` | 跟踪单个服务(`mysql` / `redis` / `backend` / `frontend`) |
| `./scripts/deploy.sh upgrade` | 升级:交互确认 → `git pull` → 停前后端 → `build --no-cache` → 跑 `init_db.py`(自动补列/种子) → `up -d` → `docker image prune -f` |
| `./scripts/deploy.sh backup` | `mysqldump` 应用库到 `./backups/nex_docus_<时间戳>.sql` |
| `./scripts/deploy.sh restore <文件>` | 覆盖式恢复数据库(需输入 `yes`) |
| `./scripts/deploy.sh uninstall` | 删除容器/网络/本地构建镜像;**不会**删除 `STORAGE_PATH` 数据 |
> `upgrade` 会执行 `git pull`:如果服务器上有本地改动或未切换分支,请先自行处理。
## 6. 备份与恢复
文档正文在文件系统、权限在数据库,**两者必须一起备份**,单独还原任何一侧都不算恢复。
```bash
# 备份
./scripts/deploy.sh backup # 数据库
tar czf nexdocus_storage_$(date +%F).tar.gz -C "$STORAGE_PATH" . # 文件 + 索引 + MySQL/Redis 数据
# 恢复(先起 mysql/redis,再灌数据,最后起应用)
docker compose up -d mysql redis
./scripts/deploy.sh restore backups/nex_docus_20260930_120000.sql
docker compose up -d backend frontend
```
迁移到新机器:拷贝 `storage` 目录 + SQL 备份 + `.env`,新机 `STORAGE_PATH` 指到新目录,恢复 SQL 后直接 `up -d`。项目的磁盘目录名是 `projects.storage_key`(UUID),与项目名解耦,可以整目录搬迁。
定期备份建议交给系统 `cron` 或备份软件,注意 `backups/` 也在 `.gitignore` 内,别提交到仓库。
## 7. 反向代理与 HTTPS
前端容器只监听 80。生产建议前置一层网关做 TLS 与域名:
```nginx
server {
listen 443 ssl http2;
server_name docs.example.com;
ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem;
client_max_body_size 100M; # 附件上传
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_kind;
proxy_set_header Connection "upgrade";
}
}
```
要点:
- 前端容器内的 nginx 已对 `/api/v1/chat/send/stream`(SSE)与 `/mcp` 关闭缓冲并放大超时;**外层网关若另有 location 命中这两条路径,也要关闭 `proxy_buffering`**,否则 AI 对话会"卡住不出字"。
- `map $http_upgrade $http_kind { default upgrade; '' close; }` 放在 `http {}` 中(示例已简化)。
- 上传大小限制要逐层一致(示例 100M)。
## 8. 上线前安全清单
- [ ] `MYSQL_ROOT_PASSWORD` / `DB_PASSWORD` / `REDIS_PASSWORD` / `SECRET_KEY` 全部改掉默认值
- [ ] `ADMIN_PASSWORD` / `DEFAULT_USER_PASSWORD` 改为随机值;启动后 `docker compose logs backend | grep 安全自检` **应无输出**(有输出说明仍在用仓库默认值)
- [ ] 首次登录后修改 `admin` 密码;删除或禁用演示账号
- [ ] MySQL / Redis 端口不对公网开放(安全组或改绑 `127.0.0.1`)
- [ ] `DEBUG=false`
- [ ] TLS 前置,HTTP 跳转 HTTPS
- [ ] `STORAGE_PATH` 与数据库备份纳入例行备份(数据库里含 Git Token、模型 API Key、分享密码明文)
- [ ] 磁盘监控:Redis 落盘失败会让整站变只读(见下)
## 9. 常见问题
| 现象 | 原因 / 处理 |
| --- | --- |
| 前端 502 / 一直转圈 | 后端还没通过 `/health`(首次 `init_db.py` 需要时间)。`./scripts/deploy.sh logs backend` 观察 |
| `backend` 反复重启,日志 `MISCONF ... RDB snapshot` | Redis 服务端落盘失败并拒绝写入 → **磁盘满或目录权限问题**。清磁盘后 `redis-cli CONFIG SET stop-writes-on-bgsave-error no` 只是临时解封,重启 Redis 会复原,必须根治磁盘 |
| `Access denied for user` | `.env` 与实际库账号不一致;改过 `DB_PASSWORD` 后已初始化的 MySQL 不会自动同步,需要进容器 `ALTER USER` 或删库重建 |
| 换机器后项目列表空了 | `STORAGE_PATH` 用了相对路径,数据实际写在别的目录 |
| 改了 `ADMIN_PASSWORD` 但登录仍是旧密码 | 管理员已存在,`init_db.py` 不会重置密码;在「系统管理 → 用户管理」里改 |
| AI 对话无引用 / 报维度不匹配 | Embedding 模型或 `ZVEC_EMBEDDING_DIM` 变过 → 重新向量化项目(项目页触发全量) |
| PDF 导出中文乱码 | 后端镜像已内置 `fonts-wqy-microhei`;若自定义基础镜像请自带中文字体 |
| `docker compose` 提示 `version` 已废弃 | 已从 `docker-compose.yml` 移除该字段;若用老 `docker-compose` v1,建议升级到 Compose v2 |
## 10. 本版验证边界
- ✅ 已验证:`./scripts/deploy.sh` 的参数路由与脚本语法、Compose 文件解析、后端镜像构建步骤文本与启动命令。
- ❌ **未在本轮做端到端 `docker compose up` 实测**(缺少可用 Docker 环境)。首个正式部署请把它当作演练:`init → status → /health → 登录 → 建项目 → 上传 → AI 问答 → 备份`,并把结果登记到 [sdd/releases/v1.0.0.md](../sdd/releases/v1.0.0.md) 的验证边界。
- ⚠️ `frontend/Dockerfile` 目前 `rm -rf package-lock.json && npm install`(历史原因是 Alpine 下 rollup 可选依赖问题),构建**不保证可重现**;确认网络环境稳定后建议改回 `npm ci`。

View File

@ -0,0 +1,133 @@
# 部署与配置变更日志
> 记录**影响部署方式、配置项、运维命令**的变更。纯功能/界面变更记录见 `docs/sdd/releases/`。
> 约定:新变更写在最上方;每条必须写清「变更 / 原因 / 影响 / 迁移动作」。
---
## v1.0.0(待发布:需 `git tag v1.0.0`)
### 1. 仓库脚本统一迁移到 `scripts/`
- **变更**:根目录的部署/启动脚本全部移入 `scripts/`,新增一键启动与停止脚本。
| 旧位置 | 新位置 | 说明 |
| --- | --- | --- |
| `./deploy.sh` | `./scripts/deploy.sh` | 部署脚本保留,路径变更 |
| — | `./scripts/start.sh` | **新增**:本地一键启动(后端 + 前端 + 探活) |
| — | `./scripts/stop.sh` | **新增**:一键停止本地服务 |
| `backend/scripts/{init_database.sql,migrate_*.sql,add_*.py,check_*.py,…}` | 删除 | 过期的一次性脚本/SQL,初始化与迁移链路见 `docs/database.md` |
| `fix_docker_deployment.sh` | 删除 | 针对已不存在的旧 compose 结构 |
- **原因**:根目录脚本混杂、命名不一致,历史一次性脚本已与新 schema 脱节,容易误执行。
- **影响**:所有引用 `./deploy.sh` 的命令、文档、CI 需改为 `./scripts/deploy.sh`。
- **迁移动作**:
```bash
./scripts/deploy.sh --help # 查看可用子命令
./scripts/start.sh # 本地开发一键启动
```
### 2. Compose 与 `.env` 配置项收敛
- **变更**:
- `docker-compose.yml` 移除废弃的顶层 `version: '3.8'` 字段。
- 移除前端镜像构建参数 `VITE_API_BASE_URL`(前端代码从未读取该变量)。
- `.env.example` 移除 `VITE_API_BASE_URL`,改为注释说明前端固定请求同源 `/api/v1`。
- **原因**:前端 API 地址固定为相对路径 `/api/v1`(开发由 vite `server.proxy` 转发,容器内由 `frontend/nginx.conf` 反代到 compose 服务 `backend`)。保留该变量会让人以为可以改前端 API 域名,实际compose默认值会直接产出坏产物。
- **影响**:升级后重新 `build` 前端镜像即可;`.env` 中残留的 `VITE_API_BASE_URL` 不再生效(可直接删除)。
- **迁移动作**:`grep -n VITE_API_BASE_URL .env` 确认并删除该行。
### 3. `backups/` 纳入 `.gitignore`
- **变更**:`.gitignore` 新增 `backups/`。
- **原因**:`./scripts/deploy.sh backup` 会在仓库根产出 `backups/`(SQL + storage 归档),此前未被忽略,易被误提交(内含数据库全量数据)。
- **迁移动作**:无需操作;历史遗留的 `backup/`(旧目录)请自行确认是否已离线保存后删除。
### 4. `deploy.sh uninstall` 行为修正
- **变更**:`uninstall` 子命令改为 `docker compose down -v --rmi local --remove-orphans`,并修正提示文案。
- **原因**:旧实现里 `docker rmi $(docker images | grep nex-docus)` 是空操作(镜像名不带该前缀),且文案暗示会删除数据;实际 storage 采用宿主机 bind mount,`down -v` 不会删除文档文件。
- **影响**:卸载会删除命名卷(MySQL/Redis 数据)与本地构建镜像;`STORAGE_PATH` 指向的宿主机目录仍需手动删除。
- **迁移动作**:卸载前先执行 `./scripts/deploy.sh backup`。
### 5. 两套默认密码并存(重要,易踩坑)
| 场景 | 初始化管理员 | 新建普通用户默认密码 | 来源 |
| --- | --- | --- | --- |
| 本地开发(`scripts/start.sh` + `backend/scripts/init_db.py`) | `admin` / `admin@123` | `User@123` | `backend/scripts/init_db.py` 与 `config.py` 的默认值 |
| Docker 部署(`docker-compose.yml` + `backend/scripts/init_db.py`) | `admin` / `Admin@123456` | `User@123456` | `.env` 的 `ADMIN_PASSWORD` / `DEFAULT_USER_PASSWORD`(compose 注入,见本文第 8 条) |
- **原因**:历史原因两套初始化路径分别写死了不同默认值。
- **影响**:跨环境复现问题时会以为"密码错了"。
- **迁移动作**:**任何环境部署后立即修改管理员密码**;生产环境必须在 `.env` 显式设置 `ADMIN_PASSWORD`。
- 同时:`init_db.py` 的管理员默认邮箱由真实域名改为占位 `admin@example.com`(原为 `admin@unisspace.com`),部署后请在「个人设置」里改成实际邮箱。
### 6. 文档位置重组
- **变更**:根目录文档全部迁入 `docs/`。
| 旧位置 | 新位置 |
| --- | --- |
| `QUICKSTART.md` | `docs/quickstart.md` |
| `DATABASE.md` | `docs/database.md` |
| `DEPLOY.md` | `docs/deploy/README.md` |
| `CHANGELOG_DEPLOY.md` | `docs/deploy/changelog.md`(本文件) |
| `PROJECT.md` / `IMPLEMENTATION_PLAN.md` | `docs/archive/`(历史归档,与现状不符) |
| `docs/manual/docus系统使用手册.md` | `docs/manual/user-guide.md` |
| `DEPLOYEE.md` / `README_DOCKER.md` | 删除(内容与 `DEPLOY.md` 重复且过期) |
- **原因**:根目录 8 份 Markdown 重复/矛盾,且部分文档含明文生产凭据。
- **迁移动作**:外部书签按上表更新;文档总入口见 `docs/README.md`。
### 7. 后端版本号为 1.0.0
- **变更**:`backend/app/core/config.py` 的 `APP_VERSION` 从 `0.9.9` 改为 `1.0.0`,与前端 `package.json`、发布计划对齐。
- **影响**:`/openapi.json` 的 `info.version` 会随之变为 1.0.0;`/health` 无变化(`/health` 仅返回状态);如运维脚本按版本号做灰度判断需重新对齐。
- **上线动作**:需执行 `git tag -a v1.0.0 -m "NEX Docus v1.0.0"`(见 [`docs/sdd/releases/v1.0.0.md`](../sdd/releases/v1.0.0.md))。
### 8. `DEFAULT_USER_PASSWORD` 现在真的可以从 `.env` 配置
- **变更**:`docker-compose.yml` 的 backend 服务补上 `DEFAULT_USER_PASSWORD=${DEFAULT_USER_PASSWORD:-User@123456}`,`.env.example` 补上该键。
- **原因**:`config.py` 一直支持该配置项,但 compose 从未透传,Docker 部署下**改 `.env` 无效**,新建用户永远是 `User@123456`。
- **影响**:升级后在 `.env` 设置即可生效;未设置时行为不变。
- **迁移动作**:`echo 'DEFAULT_USER_PASSWORD=<随机值>' >> .env && docker compose up -d backend`。
### 9. 后端启动增加「安全自检」告警
- **变更**:`Settings.security_warnings()` 在应用启动时检查 `SECRET_KEY` 与 `DEFAULT_USER_PASSWORD`,命中仓库模板里的占位/默认值时打 `WARNING`(**只告警,不阻断启动**,避免影响存量部署)。
- **原因**:compose 用 `${SECRET_KEY:-your-secret-key-change-me-in-production}` 之类的兜底默认值,运维漏配 `.env` 时会带着**公开已知的 JWT 密钥**上线且毫无提示。
- **影响**:日志里可能出现 `[安全自检]` 开头的告警行;看到这行必须改配置,不要忽略。
- **迁移动作**:`SECRET_KEY=$(openssl rand -hex 32)` 写入 `.env`;`ADMIN_PASSWORD`、`DEFAULT_USER_PASSWORD` 改为随机值。
### 10. `.env.example` 去掉真实域名邮箱
- **变更**:`ADMIN_EMAIL` 从 `admin@unisspace.com` 改为占位 `admin@example.com`。
- **原因**:模板不应携带真实域名;compose 与 `init_db.py` 的默认值本就是 `admin@example.com`,两处不一致会让运维以为默认邮箱是前者。
- **迁移动作**:已有 `.env` 自行决定是否替换;部署后在「个人设置」里改成实际邮箱。
---
## v0.9.9 及更早(历史,仅作追溯)
> 以下条目来自旧 `CHANGELOG_DEPLOY.md`,其中提到的 `./deploy.sh` 现位于 `./scripts/deploy.sh`;早期版本号 `v1.0.1` 为文案占位,git 发布线此前最高仅到 v0.9.9。
### 前端端口 80 → 8080
- 避免与宿主机常见服务冲突;可用 `.env` 的 `FRONTEND_PORT` 覆盖。
### Storage 由 Docker Volume 改为宿主机 bind mount
- 新增 `STORAGE_PATH`(默认 `./storage`),便于直接访问、备份、挂载独立磁盘,数据独立于容器生命周期。
- 旧 volume 数据一次性导出:
```bash
docker run --rm -v nex-docus_storage_data:/from -v "$(pwd)/storage:/to" alpine \
sh -c "cd /from && cp -a . /to"
```
确认无误后再 `docker volume rm nex-docus_storage_data`。
### 后端容器时区固定为 Asia/Shanghai
- 容器内 `TZ=Asia/Shanghai`,避免日志与 `created_at` 与业务时间相差 8 小时。
### 新增 `/health` 健康检查
- 供 compose `healthcheck` 与外层负载均衡探活使用。
### Nginx 增加 SSE 支持
- 知识库对话为流式响应:`proxy_buffering off`、`proxy_read_timeout` 放大,避免答案被截断。

View File

@ -1,4 +0,0 @@
# 新文件
这是使用手册,我进一步修改了内容。测试和git仓库的同步。
这次是用chatgpt修改的。

View File

@ -0,0 +1,322 @@
# NEX Docus 使用手册
> 面向**使用者与项目管理员**的功能说明(v1.0.0)。
> 部署/运维请看 `docs/deploy/README.md`,本地跑起来请看 `docs/quickstart.md`。
## 目录
- [1. 认识 NEX Docus](#1-认识-nex-docus)
- [2. 登录与账号](#2-登录与账号)
- [3. 界面总览](#3-界面总览)
- [4. 项目空间](#4-项目空间)
- [5. 文档浏览页](#5-文档浏览页)
- [6. 编辑模式(编辑 + 文档索引)](#6-编辑模式编辑--文档索引)
- [7. 成员、角色与分享](#7-成员角色与分享)
- [8. Git 仓库同步](#8-git-仓库同步)
- [9. 知识库对话(AI 问答)](#9-知识库对话ai-问答)
- [10. 个人桌面、通知与个人设置](#10-个人桌面通知与个人设置)
- [11. 系统管理(管理员)](#11-系统管理管理员)
- [12. 快捷键与交互约定](#12-快捷键与交互约定)
- [13. 常见问题](#13-常见问题)
---
## 1. 认识 NEX Docus
NEX Docus 是一个**团队文档管理平台**,把三件事放在一起:
1. **文档管理**:以「项目」为单位组织 Markdown / PDF / 图片等文件,支持目录树、在线编辑、版本化的 Git 同步。
2. **知识检索**:文档可被切块并向量化,配合自研的**全文检索 + 向量检索**双引擎。
3. **AI 问答**:在知识库对话中基于你自己的文档回答,并给出**可点击的引用来源**。
三类典型角色:
| 角色 | 主要动作 | 常用入口 |
| --- | --- | --- |
| 读者 | 浏览、搜索、问答 | 项目空间 → 文档浏览页 / 知识库对话 |
| 编辑者 | 新建、编辑、整理目录、分享 | 编辑模式 |
| 管理员 | 用户/角色/权限、模型配置、系统日志 | 系统管理 |
---
## 2. 登录与账号
访问前端地址(默认开发 `http://localhost:5173`,Docker `http://<服务器>:8080`),未登录会自动跳转 `/login`。
- 输入用户名 + 密码登录,令牌默认有效期 **24 小时**,过期后需重新登录。
- 首次部署的默认管理员账号取决于部署方式(**本地开发**与 **Docker** 默认密码不同,详见 `docs/deploy/changelog.md` §5)。
- **登录后请立即修改密码**:右上角头像 → 个人设置 → 修改密码。
- 被管理员重置密码后,新密码为系统默认密码(同样在个人设置中改为自己的密码)。
登录失败排查:提示「用户名或密码错误」时先确认部署方式对应的默认密码;提示网络错误则先确认后端 `/health` 是否可访问(`curl http://<后端地址>:8000/health`)。
---
## 3. 界面总览
登录后左侧为**功能导航**,内容由服务端下发的菜单权限决定,常见结构:
| 菜单 | 路径 | 用途 |
| --- | --- | --- |
| 个人桌面 | `/desktop` | 个人工作台:常用项目、最近文档、我的动态 |
| 管理面板 | `/dashboard` | 管理员统计视图(用户、项目、文档、活动) |
| 项目空间 → 我的项目 | `/projects/my` | 我拥有/参与的项目 |
| 项目空间 → 分享给我的 | `/projects/share` | 别人分享给我的项目 |
| 知识库空间 | `/chat` | AI 问答会话列表与对话界面 |
| 系统管理 | `/system/*` | 权限管理 / 用户管理 / 角色管理 / 模型配置 / 系统日志 |
顶部栏右侧提供:**主题切换(浅色/深色)**、**通知入口**、**用户菜单**(个人设置、退出登录)。
> 菜单可见性由「系统管理 → 权限管理」控制。看不到某个入口通常是没有该菜单权限,而不是功能坏了。
> 旧版本的知识库路径 `/knowledge`、`/knowledge/my` 会自动重定向到 `/chat`。
---
## 4. 项目空间
**项目**是文档的容器,一个项目对应磁盘上的一份文件目录。
### 4.1 创建项目
「我的项目」→ 右上角「创建项目」→ 填写项目名称、描述。创建者自动成为**所有者**。
### 4.2 项目卡片操作
每个项目卡片右上角提供:
| 图标 | 名称 | 说明 |
| --- | --- | --- |
| ✏️ / 卡片本身 | 打开 | 进入文档浏览页 |
| ⚙️ | 项目设置 | 修改名称、描述、是否公开项目、是否允许公开分享 |
| 🗄 | 知识库向量化 | 打开向量化弹窗,见 §9.1 |
| 👥 | 成员管理 | 添加/移除成员、调整角色、转移所有权 |
| 🗑 | 删除项目 | 仅所有者/管理员,操作不可逆 |
卡片下方展示**文档数量**、**最后更新时间**、**参与人数量**。
### 4.3 项目可见性
- **私有项目**:仅所有者与成员可见。
- **公开项目**:全站登录用户可见(列表只读,编辑仍需 editor/admin 角色)。
- **项目公开分享**:开启后才能生成对外分享链接(见 §7.3)。
---
## 5. 文档浏览页
路径:`/projects/<项目ID>/docs`。这是**阅读与整理**的主界面,左侧目录树 + 右侧渲染区。
### 5.1 目录树
- **点击文件**:右侧渲染 Markdown / PDF / 图片 / 代码等。
- **右键节点**(或节点右侧的更多按钮):新建文件、新建文件夹、重命名、移动、删除。
- **拖拽**:把文件或文件夹拖到目标文件夹即可移动,拖到根目录即移到顶层。
- 目录树支持折叠全部/展开全部,并展示非文档类文件(可通过筛选开关隐藏)。
### 5.2 页内操作
顶部面包屑右侧:
| 入口 | 说明 |
| --- | --- |
| 搜索文档内容 | 在项目内按关键词搜索文档正文,结果点击后直接跳到目标文件并高亮关键词 |
| 编辑 | 进入编辑模式(§6),并带上当前选中的文件 |
| 分享 | 生成该文件或项目的分享链接,可设置访问密码(§7.3) |
| 刷新 | 重新拉取目录树(外部改动、Git 同步后使用) |
| Git Pull / Git Push | 与远端仓库同步,可选择**同步范围**(仅某个目录);见 §8 |
---
## 6. 编辑模式(编辑 + 文档索引)
路径:`/projects/<项目ID>/editor`。从文档浏览页点「编辑」进入,会保留当前选中的文件。
### 6.1 三种视图模式
编辑器工具栏**右侧**的分段控件在三种模式间切换,与「全屏」按钮同排,**默认只打开编辑区**:
| 模式 | 图标 | 内容 | 适用场景 |
| --- | --- | --- | --- |
| **编辑**(默认) | 笔 | 只有 Markdown 源码编辑区,占满整个内容宽度 | 专注写作 |
| **分栏** | 双栏 | 左编辑 + 右实时预览 | 检查排版、表格、图片 |
| **预览** | 眼睛 | 只渲染结果 | 交付前自查 |
- 三个按钮只显示图标,鼠标悬停显示「编辑 · 仅编辑区」等说明文字,与同行其他图标保持一致。
- 选择会被记住(写入浏览器本地存储),下次进入编辑模式沿用;不再强制并排打开预览。
- 只有在**分栏**模式下才会出现「同步滚动」开关;纯编辑 / 纯预览时没有左右对照,该开关自动隐藏。
### 6.2 文档索引(Table of Contents)
编辑区右侧悬浮「文档索引」面板列出当前文档的标题层级:
- 目录来自 **Markdown 源码解析**,因此**在纯编辑模式下同样可用**,不依赖预览区是否打开。
- 点击条目:编辑器滚动到对应标题并把光标定位过去(编辑模式下不会跳走页面)。
- 文档没有标题时面板显示为空;标题层级过深时按层级缩进。
### 6.3 保存、重置与未保存保护
- **保存**:把编辑区内容写回服务器文件。快捷键 `Ctrl/Cmd + S`(弹窗打开或正在保存时不会触发)。
- **未保存标记**:内容与服务端版本不一致时,内容区右上角显示「未保存」标记。
- **退出保护**:点「退出编辑」时,只有在**确有未保存改动**才弹确认框(「放弃修改并退出 / 继续编辑」);关闭浏览器标签或刷新页面时,浏览器也会给出挽留提示。
- **重置**:放弃本地改动,重新加载服务器上最后一次保存的版本(同样需要确认)。
### 6.4 编辑区的其他能力
- **上传文件**:把本地文件(含图片)上传到当前目录,图片自动写入相对路径引用。
- **插入链接**:支持**页内链接**(选择本文标题生成锚点)与**页间链接**(选择项目内其他文件)。
- **新建/重命名/移动/删除**:与浏览页一致的目录树右键菜单。
- **大文档模式**:超大 Markdown 自动切换为轻量编辑器(此时不提供分栏/预览),避免卡顿。
- **全屏**:点工具栏右侧的全屏图标把编辑器铺满整个窗口,左侧项目树的操作按钮不会浮现在编辑器上;再点一次该图标或按 `Esc` 退出全屏。
---
## 7. 成员、角色与分享
### 7.1 项目角色
| 角色 | 能做什么 |
| --- | --- |
| 所有者 owner | 全部权限,含删除项目、转移所有权 |
| 管理员 admin | 管理成员与全部文档,可转让所有权以外的操作 |
| 编辑者 editor | 新建/编辑/删除文档、上传、Git 同步 |
| 查看者 viewer | 只读浏览、搜索、参与 AI 问答 |
### 7.2 管理成员
项目卡片 → 成员管理 → 按用户名搜索添加,并为成员指定角色;也可以在此**转移项目所有权**。移除成员不会删除其创建的文档。
### 7.3 分享链接
- **项目分享**:项目设置里开启「项目公开分享」后生成链接,形如 `/share/project/<分享码>`。
- **文件分享**:文档浏览页对单个文件点「分享」,形如 `/share/file/<分享码>`。
- 可设置**访问密码**,访问时需先输入密码。
- 分享页**只读**,不提供编辑入口;关闭「公开分享」会让既有链接失效。
> 安全提示:分享链接无需登录即可访问,请勿把含敏感信息的文档设为公开分享;管理员应定期清理不再使用的分享。
---
## 8. Git 仓库同步
用于把项目目录和一个 Git 仓库对接(适合团队用 IDE/命令行协作,同时保留 Web 编辑)。
**配置**:项目卡片 → 成员管理旁的 Git 仓库管理入口 → 填写仓库别名、Git 仓库地址、分支、用户名、Token/密码。
**使用**:文档浏览页顶部「Git Pull / Git Push」,两者都可**选择同步范围**(只同步某个子目录),避免大仓一次性同步。
前提与注意:
- 服务端所在主机必须安装 `git` 命令(后端通过命令行调用 git,不是内置实现)。
- 私有仓库请优先使用 **Access Token** 而不是账号密码。
- 冲突时同步会失败并返回 git 的原始报错,请在本地仓库解决冲突后重试。
- 同步完成后点「刷新」重载目录树。
---
## 9. 知识库对话(AI 问答)
路径:`/chat`(新建会话 `/chat/new`)。左侧会话列表,右侧对话区。
### 9.1 先决条件:模型配置 + 向量化
1. 管理员在「系统管理 → 模型配置」中启用至少一个 **chat** 模型(回答用)和一个 **embedding** 模型(向量化用)。
2. 项目卡片 → 「知识库向量化」→ 选择**增量向量化**(只处理新增/变更文档)或**全量向量化**(重建索引)。弹窗展示最近任务的状态、进度与已向量化数量。
> 若弹窗提示「尚未配置可用的向量模型」,说明 embedding 模型未启用,或向量维度与模型实际输出不一致(`ZVEC_EMBEDDING_DIM`)。
### 9.2 提问
- 新建对话时**选择要检索的项目/知识库**,可以选择多个。
- 答案以流式返回,过程中可点**停止**中断生成。
- 回答下方展示**引用来源**(命中的文档与片段),点击可跳转到原文档位置。
- 界面给出**耗时**等运行信息,便于判断是检索慢还是模型慢。
### 9.3 检索行为
问答走**双引擎**:关键词(全文)检索 + 向量(语义)检索,合并后交给模型。因此:
- 精确术语/代码标识符命中靠全文检索;换词、口语化提问靠向量检索。
- **文档改了但答案还是旧的** → 通常是忘了做增量向量化。
- 没向量化过的项目只能靠全文检索,质量会明显下降。
---
## 10. 个人桌面、通知与个人设置
- **个人桌面** `/desktop`:个人工作台,快速进入常用项目与最近编辑的文档。
- **通知中心** `/notifications`:查看项目邀请、文档变更、向量化/同步任务结果等站内通知,支持标记已读。
### 10.1 个人设置(`/profile`)
| 标签页 | 内容 |
| --- | --- |
| 个人资料 | 修改昵称、邮箱、头像(图片不超过 1MB) |
| 修改密码 | 校验原密码后设置新密码,修改后需重新登录 |
| MCP 接入 | 查看/复制本人的 `X-Bot-Id` 与 `X-Bot-Secret`,可**重新生成** Secret |
> **重新生成 MCP Secret 会立即让旧的 Secret 失效**,用旧凭证配置的 MCP 客户端(如 IDE、机器人)需要同步更新。MCP 的接入方式与可用工具见 `docs/sdd/integrations/mcp.md`。
---
## 11. 系统管理(管理员)
| 页面 | 路径 | 作用 |
| --- | --- | --- |
| 权限管理 | `/system/permissions` | 维护菜单与权限点,并给角色授权;决定用户能看到哪些入口 |
| 用户管理 | `/system/users` | 新增用户、启停用、重置密码、分配角色 |
| 角色管理 | `/system/roles` | 新增角色、改名与描述、绑定权限 |
| 模型配置 | `/system/model-configs` | 维护 LLM 服务:类型分 **chat / embedding**,配置名称、服务地址、API Key、模型名、维度,并设为默认/启用 |
| 系统日志 | `/system/logs` | 查看操作与系统日志,用于排查问题 |
模型配置注意:
- **embedding 模型一旦用于向量化,维度就不能随意改**;换模型需同时更新 `ZVEC_EMBEDDING_DIM` 并做**全量向量化**。
- 使用自签名 HTTPS 的私有模型服务时,需要 `DISABLE_SSL_VERIFY=true`(仅该场景,生产保持 `false`)。
- API Key 属于敏感信息,只有管理员可维护,界面上会做掩码处理。
---
## 12. 快捷键与交互约定
| 场景 | 操作 |
| --- | --- |
| 保存当前文档 | `Ctrl/Cmd + S`(编辑模式) |
| 打开文件/目录操作菜单 | 目录树节点**右键** |
| 移动文件/目录 | 目录树中**拖拽**到目标文件夹 |
| 返回上一级视图 | 面包屑点击,或浏览器后退 |
| 深浅色切换 | 顶部栏主题开关,选择会被记住 |
统一交互约定:
- **危险操作**(删除项目/文件/成员、Git 推送等)一律先弹确认框,确认后才执行。
- **操作结果**用轻提示反馈(不打断操作);表单错误就近显示在字段下方。
- 列表/树加载时显示骨架或加载态;空数据统一显示空状态占位与下一步引导。
- 只读角色(viewer)不会看到编辑、删除类按钮。
---
## 13. 常见问题
**Q:打开项目只有阅读区,找不到编辑按钮?**
A:当前账号在该项目中是「查看者」。让所有者/管理员在成员管理里把角色提升为「编辑者」或「管理员」。
**Q:编辑模式右边没有预览,是不是坏了?**
A:不是。v1.0.0 起**默认只打开编辑区**,点编辑器工具栏右侧的分段控件切到「分栏」或「预览」即可;选择会被记住。
**Q:文档索引(TOC)里是空的?**
A:目录来自 Markdown 标题(`#`/`##`…)。文档没有标题,或超大文档走了大文档模式时,索引可能为空或受限。
**Q:AI 问答说「文档里没有相关内容」,但文档确实存在?**
A:按顺序检查:① 会话是否选中了正确的项目;② 该项目是否做过(增量)向量化;③ 模型配置里 chat / embedding 模型是否都已启用。
**Q:向量化一直不动或失败?**
A:先看弹窗里最近任务的报错,再看「系统日志」和后端日志。常见原因是 embedding 服务不可达、维度不匹配,或 Redis 拒绝写入(`MISCONF`,见 `docs/quickstart.md` FAQ)。
**Q:Git 同步报错 `git: command not found`?**
A:后端主机未安装 git。容器部署时需要在镜像内提供 git(`docs/deploy/README.md` 有说明)。
**Q:分享链接打不开?**
A:确认项目设置里「项目公开分享」仍为开启状态;文件分享需对应文件仍存在;有密码的要先输密码。
**Q:左侧菜单少了一块?**
A:菜单由权限控制,见「系统管理 → 权限管理」中该角色的菜单授权。

162
docs/quickstart.md 100644
View File

@ -0,0 +1,162 @@
# 快速上手(开发环境)
本文面向**本地开发**。服务器部署请看 [deploy/README.md](deploy/README.md)。
## 1. 前置要求
| 依赖 | 版本 | 说明 |
| --- | --- | --- |
| Python | 3.10+(推荐 3.12) | 后端运行时 |
| Node.js | 18+ | 前端构建(vite 5) |
| MySQL | 8.0(utf8mb4) | 元数据/权限;5.7 未纳入支持范围 |
| Redis | 5+ | 会话缓存、索引同步信号 |
| git | 任意新版 | 仅在使用「Git 仓库同步」功能时需要 |
## 2. 一键启动(推荐)
```bash
./scripts/start.sh
```
脚本按顺序执行:环境检查 → 创建 `backend/venv` → 生成 `backend/.env` → 安装前后端依赖 → **真实校验** MySQL/Redis → 幂等初始化数据库 → 启动后端与前端。
首次执行会因为需要填写连接信息而**主动中断**:
```text
! backend/.env 不存在,正在从模板创建(请填写数据库/Redis 连接信息)
✗ 请编辑 backend/.env 填写数据库与 Redis 连接信息后重新运行
```
填好 `DB_HOST/DB_USER/DB_PASSWORD/DB_NAME`、`REDIS_HOST/REDIS_PASSWORD/REDIS_DB` 后再次运行即可。
常用参数:
| 命令 | 作用 |
| --- | --- |
| `./scripts/start.sh` | 启动后端 + 前端(前台运行,Ctrl+C 全停) |
| `./scripts/start.sh --backend` | 只启动后端 |
| `./scripts/start.sh --frontend` | 只启动前端 |
| `./scripts/start.sh --install` | 只准备环境(venv / npm 依赖 / 建表),不启动服务 |
| `./scripts/start.sh --init-db` | 强制重跑一次数据库初始化(幂等) |
| `./scripts/start.sh --port 8001` | 换后端端口(用环境变量覆盖,不改 `backend/.env`) |
| `./scripts/start.sh --docker` | 转交 `scripts/deploy.sh start` |
| `./scripts/start.sh --daemon` | 启动后立即返回(服务后台常驻,用 `stop.sh` 停止) |
| `./scripts/stop.sh` | 停止由 start.sh 启动的进程 |
启动成功输出:
```text
✓ MySQL 连接正常(MySQL 8.0.37)
✓ Redis 连接正常
✓ 后端健康检查通过(/health)
✓ NEX Docus 已启动
后端 API http://localhost:8000 文档 http://localhost:8000/docs
前端页面 http://localhost:5173
```
进程 pid 与日志在 `.run/`(`backend.log`、`frontend.log`),该目录已被 gitignore。
## 3. 手动启动(不使用脚本时)
```bash
# 后端
cd backend
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt # 国内网络可加 -i https://pypi.tuna.tsinghua.edu.cn/simple
cp -n .env.example .env 2>/dev/null || true # 没有模板时按下一节的键自行创建 .env
python scripts/init_db.py # 建表 + 种子数据(幂等)
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
# 前端(另一个终端)
cd frontend
npm install
npm run dev
```
> `requirements.txt` 含 `sentence-transformers`(会拉取 PyTorch,体积以 GB 计)与 `zvec`。若只用远程 OpenAI 兼容 Embedding 接口,可以只装它们之外的依赖;`sentence_transformers` 是延迟导入的,不装也能启动。
## 4. `backend/.env` 配置项
后端配置由 `app/core/config.py` 用 pydantic-settings 读取 `.env`(同名环境变量优先级更高)。
| 键 | 默认值 | 说明 |
| --- | --- | --- |
| `APP_NAME` | `NEX Docus` | 标题 |
| `APP_VERSION` | `1.0.0` | 出现在根路径 `GET /` 的 `version`、`/docs` 标题与 `/openapi.json` |
| `DEBUG` | `True` | **为 True 时 SQLAlchemy 打印全量 SQL,生产必须关闭** |
| `HOST` / `PORT` | `0.0.0.0` / `8000` | 监听地址(`uvicorn` 命令行参数优先) |
| `DB_HOST` `DB_PORT` `DB_USER` `DB_PASSWORD` `DB_NAME` `DB_CHARSET` | — / 3306 / — / — / — / utf8mb4 | MySQL 连接;密码会自动做 URL 编码 |
| `REDIS_HOST` `REDIS_PORT` `REDIS_PASSWORD` `REDIS_DB` | — / 6379 / — / 8 | Redis 连接 |
| `SECRET_KEY` | — | JWT 签名密钥,务必随机化;仍为模板占位值或长度 <16 时启动会打「安全自检」告警 |
| `ALGORITHM` / `ACCESS_TOKEN_EXPIRE_MINUTES` | `HS256` / `1440` | Token 配置 |
| `DEFAULT_USER_PASSWORD` | `User@123456` | 管理员新建用户时的初始密码;仍为默认值时启动会打「安全自检」告警 |
| `STORAGE_ROOT` `PROJECTS_PATH` `USERS_PATH` `TEMP_PATH` | `/data/nex_docus_store/...` | 文件存储根目录与各子目录;本地开发通常指向 `./storage/...` |
| `AVATAR_MAX_SIZE` | `1048576` | 头像上传上限(字节) |
| `CHUNK_SIZE` / `CHUNK_OVERLAP` | `800` / `150` | RAG 分块字符数与重叠 |
| `CORS_ORIGINS` | `["http://localhost:5173","http://localhost:3000"]` | JSON 数组字符串 |
| `LOG_LEVEL` / `LOG_PATH` | `INFO` / `logs` | 日志 |
| `ADMIN_USERNAME` `ADMIN_PASSWORD` `ADMIN_EMAIL` `ADMIN_NICKNAME` | `admin` / `admin@123` / `admin@example.com` / `系统管理员` | 仅 `init_db.py` 首次创建管理员时读取(Docker 部署由 compose 注入,默认口令见 [deploy/changelog.md](deploy/changelog.md#5-两套默认密码并存重要易踩坑)) |
| `ZVEC_DATA_DIR` / `ZVEC_EMBEDDING_DIM` | `STORAGE_ROOT/vector_index` / `1536` | 向量库目录与维度(维度必须与所选 embedding 模型一致) |
| `DISABLE_SSL_VERIFY` | `false` | 仅当 LLM/Embedding 服务使用自签名证书时开启 |
> Docker 部署用的是仓库根目录 `.env`(键名不同,见 [deploy/README.md](deploy/README.md) 与 `.env.example`),与 `backend/.env` 是两套配置,别混用。
## 5. 默认账号
`backend/scripts/init_db.py` 首次创建管理员时使用:
- 用户名 `admin`
- 密码 `admin@123`
已存在同名用户时不会重置密码。生产环境请用 `ADMIN_PASSWORD` 覆盖并在首次登录后立即修改。
## 6. 前端约定
- 开发端口 `5173`,`vite.config.js` 已把 `/api` 代理到 `http://localhost:8000`,因此**前端不需要配置后端地址**。
- 路径别名 `@` → `frontend/src`。
- 页面一律在 `src/App.jsx` 中用 `lazy()` 注册,路由级懒加载。
- 主题/颜色取自 `src/styles/design-tokens.css` 与 `src/theme/antdTheme.js`,不要写死色值。
- 全局提示统一 `@/components/Toast`(含 `Toast.confirm`),不要散用 `Modal.confirm` / `message.*`。
## 7. 自检清单
```bash
curl -s http://127.0.0.1:8000/health # {"status":"healthy"}
curl -s http://127.0.0.1:8000/openapi.json | head -c 120 # version 应为 1.0.0
open http://127.0.0.1:8000/docs # Swagger
open http://localhost:5173 # 登录页
```
## 8. 常用开发命令
```bash
cd frontend && npm run lint # ESLint(当前基线:0 error / 25 warning)
cd frontend && npm run lint:strict # 警告也视为失败
cd frontend && npm run build # 生产构建
cd backend && ./venv/bin/pip install -r requirements-dev.txt # 安装 pytest 等开发依赖
cd backend && ./venv/bin/python -m pytest # 当前基线:40 passed
```
> 注意:`npx eslint src` 会**秒退且全通过**——ESLint 8 对目录默认只收 `.js`,必须写 `eslint "src/**/*.{js,jsx}"` 或用 `npm run lint`(已带 `--ext js,jsx`)。
## 9. 数据库结构与迁移
- 建表:`init_db.py` 调 `Base.metadata.create_all`,表结构来自 `app/models/`;**新增模型必须在 `app/models/__init__.py` 里导入**,否则该表不会被创建(历史事故:`notifications`、`project_git_repos` 曾因此在新库缺失)。
- 种子数据:角色(super_admin / admin / user)、系统菜单、管理员账号,全部幂等增量。
- 补列:`app/core/migrations.py::migrate_schema()` 在服务启动前/初始化时执行,只补已知缺失列。
- 变更流程:改模型 → 本地重跑 `./scripts/start.sh --init-db` → 若是老库需要的列,补进 `migrations.py` → 更新 [database.md](database.md)。仓库不再接收散落的 `*.sql` 补丁。
## 10. 常见问题
| 现象 | 原因 / 处理 |
| --- | --- |
| `✗ Redis 连接失败:AUTH 失败` | `REDIS_PASSWORD` 与服务端不一致;服务端未设密码时留空 |
| `Redis 拒绝写入:MISCONF Redis is configured to save RDB snapshots` | 服务端 RDB/AOF 落盘失败(磁盘满或目录无权限)。**先修磁盘**;应急可 `redis-cli CONFIG SET stop-writes-on-bgsave-error no`,但该配置重启即失效,不是修复 |
| `✗ MySQL 连接失败:Access denied` | 用户/密码/授权主机不匹配;确认对 `DB_NAME` 有权限 |
| 后端起来了但某些表不存在 | 模型未在 `app/models/__init__.py` 注册;补 import 后 `--init-db` |
| 前端 401 后一直跳登录页 | Token 过期(`ACCESS_TOKEN_EXPIRE_MINUTES`),或 `SECRET_KEY` 改过导致旧 Token 失效 |
| 前端请求 404 | 后端未启动/端口不是 8000(改了 `--port` 就要同步改 `vite.config.js` 的 proxy target) |
| `pip install` 卡在 torch | 网络问题;用清华源,或临时跳过 `sentence-transformers`(仅本地 Embedding 需要) |
| 控制台刷屏 SQL | `DEBUG=True` 的正常行为,关掉即止 |
| 端口被占用 | `lsof -ti:8000 \| xargs kill`;后端也可 `./scripts/start.sh --port 8001` |

View File

@ -28,8 +28,9 @@ docs/sdd/
│ └── git.md # 项目 Git 仓库集成
├── releases/
│ ├── README.md # 公开版本与资产索引
│ ├── v0.9.6.md # v0.9.6 历史升级记录(整合自 docs/)
│ └── v0.9.9.md # 当前基线发布(对齐 git)
│ ├── v1.0.0.md # 当前发布(首个正式版本,含验证边界与上线清单)
│ ├── v0.9.9.md # 历史发布记录
│ └── v0.9.6.md # 历史升级记录(整合自 docs/)
└── specs/
├── README.md # 功能规格索引
├── _template/ # 新规格模板
@ -38,11 +39,12 @@ docs/sdd/
## 当前状态(Status)
- **SDD 文档状态**:基线(Baseline)· 对齐 git 当前版本 v0.9.9
- **适用代码基线**:当前仓库(backend + frontend + docker-compose 部署)
- **SDD 文档状态**:v1.0.0 发布评审(Release Candidate)
- **适用代码基线**:`ba80d28 fix project role permission`(main)+ 发布前整改(未提交)
- **规格覆盖**:11 个功能单元(DV-0001 ~ DV-0011);价值主张 PO-1~5;架构决策 ADR-0001~0008
- **已知整改项**:见 governance「开放问题」与各规格 tasks.md 中的“待整改/待决”标记(如敏感日志脱敏 TS-12、Compose v2 官方化 TS-13)
- **版本基线**:以 git 为准(当前 v0.9.9;见 releases/README.md 与 v0.9.9.md 版本对齐说明;代码字段 1.0.0 为遗留占位)
- **验证边界**:已复验项(pytest 40 passed / eslint 0 error / vite build / 编辑器与权限的浏览器实测)与**未验证项**(Docker 端到端、MCP 实连、Git 真实远端同步)统一登记在 [releases/v1.0.0.md](releases/v1.0.0.md)
- **已知整改项**:以 [releases/v1.0.0.md 的「已知问题」表](releases/v1.0.0.md#已知问题开放项)为总表(含 P0 运维阻塞:Redis RDB 失败);细节见 governance「开放问题」与各规格 tasks.md
- **版本基线**:**v1.0.0**(`APP_VERSION` 与 `package.json` 已对齐;**git tag `v1.0.0` 待创建**,属上线动作,见 releases/README.md)
## 如何使用本中心(工作流)
@ -71,9 +73,9 @@ docs/sdd/
## 与外部文档的关系
SDD 中心**汇总并指向**仓库既有的详细材料,而非重复复制全部内容:
- 数据库细节 → 仓库根 `DATABASE.md`(SDD 只保留 ER 概览与规格化的链路表)
- 部署运维 → `DEPLOY.md`、`README_DOCKER.md`、`CHANGELOG_DEPLOY.md`(及已并入的 docs/DOCKER_DOCS_SETUP 历史,见 archive.md)
- 历史变更 → `docs/UPGRADE_v0.9.6.md`、`docs/MIGRATION.md`(已整合/弃置,见 archive.md)
- 数据库细节 → [`docs/database.md`](../database.md)(SDD 只保留 ER 概览与规格化的链路表)
- 部署运维 → [`docs/deploy/README.md`](../deploy/README.md)、[配置变更日志](../deploy/changelog.md)(原 `DEPLOY.md`/`README_DOCKER.md`/`CHANGELOG_DEPLOY.md` 已合并去重)
- 发布与已知问题 → [releases/v1.0.0.md](releases/v1.0.0.md);历史变更 → [releases/v0.9.6.md](releases/v0.9.6.md)(原 `docs/UPGRADE_v0.9.6.md`);`docs/MIGRATION.md` 已弃置,见 [archive.md](archive.md)
- 结构规范 → `architecture/standards/`(原 docs/code-structure-* 已迁入)
> 本中心是入口与追踪层;具体逐表 DDL、逐配置项说明等细节仍以被指向的源文档为准。

View File

@ -20,7 +20,7 @@
| --- | --- | --- | --- |
| OP-1 | Alembic vs 幂等 ALTER | 采用幂等 ALTER(`migrations.py`) | 评估引入 Alembic 以支撑大规模结构变更 |
| OP-2 | 敏感日志 | `security.py`/`deps.py` 记录 SECRET_KEY 前缀/JWT 载荷 | 脱敏,见 OI-1 |
| OP-3 | Compose v2 官方化 | deploy.sh 探测 v2 但根文档仍提及 v1 | 统一 v2 路径,见 OI-2 |
| OP-3 | Compose v2 官方化 | 文档已统一 v2 与 `./scripts/deploy.sh` 路径,待一次真实环境端到端复验 | 见 OI-2、[v1.0.0 验证边界](../releases/v1.0.0.md) |
| OP-4 | 测试覆盖 | 仅 3 个后端单测 | 增加集成/E2E,见 OI-3 |
| OP-5 | 实时协同编辑 | 明确不在此版本做(vision 边界) | 另立项评估 OT/CRDT |
| OP-6 | 多实例/高可用 | 单机部署,无状态服务仅后端(Redis/DB 独立) | 若需要再评估 |

View File

@ -7,9 +7,9 @@
仅支持 **Docker Compose v2**(`docker compose` 子命令)部署;弃用 v1。`deploy.sh` 中 `get_compose_cmd` 优先探测 v2,回退 v1 仅作兼容提示。
## 后果
- 升级路径:安装 `docker-compose-plugin`(v2)并卸载 v1 后即可用 `./deploy.sh upgrade` 或 `docker compose up -d --build`。
- 升级路径:安装 `docker-compose-plugin`(v2)并卸载 v1 后即可用 `./scripts/deploy.sh upgrade` 或 `docker compose up -d --build`。
- `docker-compose.yml` 顶部 `version:` 仅产生弃用警告,不影响功能。
## 关联
NFR-6, OI-2;部署细节见 `DEPLOY.md`、`README_DOCKER.md`。
NFR-6, OI-2;部署细节见 [`docs/deploy/README.md`](../../../deploy/README.md)。

View File

@ -1,6 +1,6 @@
# 阶段性路线图(Roadmap)
> 基于仓库现状(IMPLEMENTATION_PLAN.md 三阶段已基本完成)与 SDD 梳理结果,重新组织为阶段化路线图。状态为 SDD 核对后的推断。
> 基于仓库现状(原 IMPLEMENTATION_PLAN.md 三阶段已基本完成,现已归档到 [archive/IMPLEMENTATION_PLAN.md](../../archive/IMPLEMENTATION_PLAN.md))与 SDD 梳理结果,重新组织为阶段化路线图。状态为 SDD 核对后的推断。
## 阶段一:MVP 文档平台(已完成)
- 认证与会话(DV-0001)

View File

@ -6,20 +6,22 @@
- 版本号语义化(MAJOR.MINOR.PATCH)。
- 发布前必须:对应 DV verification.md 证据通过;ADR 无未决的重大分歧。
- 发布文件中须给出「验证边界」:哪些 DV 被覆盖、如何复验、已知限制。
- 部署相关变更同步到 `DEPLOY.md` / `CHANGELOG_DEPLOY.md`。
- **版本以 git 为准**:发布必须对应一个 git 版本标记(提交信息中的 vX.Y.Z 或 tag);代码内版本字段应与 git 标记一致。
- 部署/配置相关变更同步到 [`docs/deploy/README.md`](../../deploy/README.md) 与 [配置变更日志](../../deploy/changelog.md)。
- **版本以 git 为准**:发布必须对应一个 git 版本标记(优先使用 tag;历史提交以提交信息中的 vX.Y.Z 为准);代码内版本字段应与 git 标记一致。
## 版本列表(对齐 git)
| 版本 | 文件 | 对应 git 标记 | 主要规格覆盖 | 状态 |
| --- | --- | --- | --- | --- |
| v0.9.9 | [v0.9.9.md](v0.9.9.md) | `416ef48 v0.9.9`(当前基线,HEAD `4ef3c92`) | 阶段一~三全部 DV | 当前基线 |
| **v1.0.0** | [v1.0.0.md](v1.0.0.md) | 基线 `ba80d28`,**待打 tag `v1.0.0`** | DV-0001 ~ DV-0011 全量(首个正式版本) | 待发布 |
| v0.9.9 | [v0.9.9.md](v0.9.9.md) | `416ef48 v0.9.9` / `f92fff6 基线版本` / `eea3b96 v0.9.9-SP1` | 阶段一~三全部 DV | 历史发布记录 |
| v0.9.6 | [v0.9.6.md](v0.9.6.md) | `c005964 v0.9.6` | MCP 内集成/凭证管理/Python3.12/storage 持久化(历史) | 历史发布记录 |
> git 发布线:v0.9.1 → v0.9.2 → v0.9.6 → v0.9.7 → v0.9.8 → **v0.9.9**。仓库**未打 git tag**;上表以提交信息中的版本标记为准。
> git 发布线:v0.9.1 → v0.9.2 → v0.9.6 → v0.9.7 → v0.9.8 → v0.9.9(含 `-SP1`)→ **v1.0.0(待打 tag)**。
> 仓库历史**未使用 git tag**,上表在 v1.0.0 之前以提交信息中的版本标记为准;自 v1.0.0 起发布**必须打 tag**。
>
> v0.9.6 为**历史发布记录**(整合自原 `docs/UPGRADE_v0.9.6.md`);v0.9.9 为**当前基线**。
> v1.0.0 是首个正式大版本:代码内版本字段(`APP_VERSION`、`package.json version`)已统一为 1.0.0,此前的「遗留占位」说明随本发布失效。
>
> ⚠️ 代码内版本字段(`APP_VERSION`、`package.json version`)与部分旧文档标注为 1.0.0,与 git 标记不符,属遗留占位,见 v0.9.9.md「版本对齐说明」。
> v0.9.6 / v0.9.9 为**历史发布记录**(v0.9.6 整合自原 `docs/UPGRADE_v0.9.6.md`);其中的「版本对齐说明」「已知差异」保留原文,仅用于追溯。
## 历史部署变更参考(非发布、仅供追踪)
- `CHANGELOG_DEPLOY.md`:v1.0.1 前端端口 80→8080、storage 目录映射(STORAGE_PATH)等(该记录与 git 发布线冲突,已标注意外,见 archive/版本对齐)。
## 部署/配置类变更
不占用发布号的运维与配置变更,记录在 [docs/deploy/changelog.md](../../deploy/changelog.md)(原根目录 `CHANGELOG_DEPLOY.md`,已迁入 `docs/`)。

View File

@ -1,6 +1,8 @@
# Release v0.9.9(当前基线)
# Release v0.9.9(历史记录)
> 基于 `git log` 版本标记对应的最新发布提交(`416ef48 v0.9.9`)与当前 HEAD(`4ef3c92 优化了显示`,位于 v0.9.9 之后)。仓库未打 git tag,此记录以提交信息中的版本标记为准。
> **本文为历史发布记录**:v0.9.9 线已由 [v1.0.0](v1.0.0.md) 接替,文中「当前基线」「HEAD」等措辞为撰写当时(`4ef3c92`)的状态,保留原文以供追溯。
>
> 基于 `git log` 版本标记对应的最新发布提交(`416ef48 v0.9.9`)与当时的 HEAD(`4ef3c92 优化了显示`)。仓库未打 git tag,此记录以提交信息中的版本标记为准;v0.9.9 线后续还有 `f92fff6 v0.9.9 基线版本` 与 `d6ebd62`/`eea3b96 v0.9.9-SP1`。
## 元数据
- **版本**:v0.9.9(当前基线)
@ -19,7 +21,7 @@
## 验证边界
- 自动化测试:`backend/tests/test_chat_citations.py`、`test_model_and_vector_configuration.py`、`test_search_service.py`。
- 运行验证:`/health` 返回 healthy;Swagger `/docs` 可用。
- 部署验证:Compose v2 `docker compose up -d --build`;`./deploy.sh upgrade`。
- 部署验证:Compose v2 `docker compose up -d --build`;`./scripts/deploy.sh upgrade`(脚本自 v1.0.0 起位于 `scripts/`)。
- 已知限制:单机部署;无实时协同编辑;测试覆盖待增强(OI-3)。
## 已知问题
@ -27,9 +29,9 @@
| --- | --- | --- |
| OI-1 | 敏感日志脱敏 | 待整改 |
| OI-2 | Compose v2 官方化 | 待整改 |
| OI-4 | 版本字段 1.0.0 与 git 标记不符,需统一 | 待整改(见本文档「版本对齐」说明) |
| OI-4 | 版本字段 1.0.0 与 git 标记不符,需统一 | **已关闭**(v1.0.0 立项并显式建 tag,见 [v1.0.0.md](v1.0.0.md)) |
## 版本对齐说明
- git 发布线:v0.9.1 → v0.9.2 → v0.9.6 → v0.9.7 → v0.9.8 → v0.9.9(`416ef48`)。
- **无 v1.0.0** 的 git 版本;代码 `APP_VERSION=1.0.0`、`package.json version=1.0.0` 与文档中的 v1.0.0/v1.0.1 均为遗留占位,与 git 不符。
- 建议:下次打 tag 时以 v0.9.9 为基线;若计划升级 v1.0.0 则应显式建 tag 并同步代码字段。
- git 发布线:v0.9.1 → v0.9.2 → v0.9.6 → v0.9.7 → v0.9.8 → v0.9.9(`416ef48`,含 `-SP1`)。
- 撰写本文时**尚无 v1.0.0** 的 git 版本,代码 `APP_VERSION=1.0.0`、`package.json version=1.0.0` 与文档中的 v1.0.0/v1.0.1 均为遗留占位。
- **后续处置(已完成)**:v1.0.0 已作为首个正式版本立项,代码字段保留 1.0.0 并通过显式建 tag 对齐,见 [v1.0.0.md](v1.0.0.md)。OI-4 在本发布关闭。

View File

@ -0,0 +1,129 @@
# Release v1.0.0(首个正式版本)
> 本文件是 **v1.0.0 的发布记录与验证边界**。发布内容 = git 基线 `ba80d28 fix project role permission`(v0.9.9-SP1 之后)+ 当前工作区未提交的发布前整改。
> **上线动作**:本发布**尚未打 git tag**,需在整改提交合并后执行 `git tag -a v1.0.0 -m "NEX Docus v1.0.0"` 并推送,使「版本以 git 为准」的规则重新成立。
## 元数据
| 项 | 值 |
| --- | --- |
| 版本 | v1.0.0(首个正式版本 / 第一个大版本) |
| git 基线提交 | `ba80d28 fix project role permission`(main) |
| 上一条版本标记 | `eea3b96 v0.9.9-SP1`(非连续,其间的 `5d41339`/`fef17e5`/`b3f5fb0`/`dfd4618` 无版本标记) |
| git tag | **待创建** `v1.0.0` |
| 代码内版本字段 | `backend/app/core/config.py` `APP_VERSION = "1.0.0"`;`frontend/package.json` `version = "1.0.0"` — **已对齐** |
| 适用组件 | backend(FastAPI + SQLAlchemy + MySQL + Redis)、frontend(React 18 + Vite + antd + ByteMD)、docker-compose 部署编排、`scripts/` 运维脚本 |
| 数据库 | 无 schema 变更(本发布不含迁移;表结构与 `docs/database.md` 一致,共 18 张表) |
| 破坏性变更 | 脚本路径 `./deploy.sh` → `./scripts/deploy.sh`;`.env` 移除 `VITE_API_BASE_URL`(详见[部署与配置变更日志](../../deploy/changelog.md#v100未发布)) |
> 历史遗留说明:v0.9.9.md 与旧 `releases/README.md` 曾把代码里的 1.0.0 判定为「遗留占位」。本发布通过**显式建 tag** 把版本线正式推进到 v1.0.0,占位问题到此结束。
## 本发布范围
### 1. 文档编辑体验(DV-0003)
- 编辑器**默认只打开编辑区**(`edit` / `split` / `preview` 三态可切换,选择写入 `localStorage` 并按用户记忆)。旧行为是编辑区与预览区同时打开。
- **视图模式切换并入编辑器工具栏**:三态分段控件由内容区标题栏移到 ByteMD 编辑器工具栏**右侧**,与内置「全屏」按钮同排(`createPortal` 注入 `.bytemd-toolbar-right` 末个子节点),只保留图标 + Tooltip(「编辑 · 仅编辑区」等);与重复的内置开关(文档索引 / 帮助 / 仅编辑 / 仅预览)统一隐藏,标题栏只留「未保存」标记与保存/重置。
- ByteMD 由 Svelte 渲染且会在 StrictMode 双挂载、切换文档时重建 DOM,插槽采用 `MutationObserver` + rAF **持续校验**(缓存节点 `isConnected` 失效即重找),避免 Portal 渲染到游离节点。
- 仅**分栏**模式显示「同步滚动」开关;纯编辑 / 纯预览下自动隐藏。
- **修复编辑器全屏层级**:ByteMD 的 `.bytemd-fullscreen` 是 `position: fixed` 但没有 `z-index`,会被 `.mode-switch`(`z-index: 1`)等浮层压住,导致左侧项目树的操作按钮浮现在全屏编辑器上。现在显式抬到 `var(--z-float)`(900),文档索引浮标再抬一层仍可点;并补 **`Esc` 退出全屏**(自动补全等浮层打开时不抢占)。
- **Table of Contents 不再依赖预览区**:新增 `frontend/src/utils/markdownToc.js`,大纲直接从 Markdown **源码**解析(标题层级 + 滚动定位),因此在纯编辑视图下 TOC 依然可用(`FloatingToc`)。
- **未保存保护**:以 `contentBaseline` 计算 `isDirty` → 标题栏显示「未保存」标记;离开页面(`beforeunload`)与返回项目列表时拦截确认;新增全局 **Ctrl/Cmd + S** 保存并带 loading 并发保护。
### 2. UI / 交互 / 设计规范统一(跨 DV)
- 新增设计令牌层 `frontend/src/styles/design-tokens.css` 与 antd 主题映射 `frontend/src/theme/antdTheme.js`;22 个文件中的硬编码颜色/圆角/间距替换为令牌。
- 新增统一反馈组件 `frontend/src/components/Feedback`(Toast / `Toast.confirm`),16 处 `Modal.confirm` 迁移;新增路由级 `PageLoading`,全部路由改为 `React.lazy` 代码分割。
- 新增 `frontend/.eslintrc.cjs` 固化前端规范;删除 33 个从未被引用的死代码组件/模块,删除无效的「语言切换」假开关。
### 3. 后端正确性修复
- `app/models/__init__.py` 补齐 `Notification`、`ProjectGitRepo` 注册 —— 此前单独导入这两个模型会缺表,影响 metadata 完整性与新库初始化。
- `GET /api/v1/projects/{id}` 由 500(`MissingGreenlet`)修复为正常返回:改用 `require_project_read_access(..., allow_public=True)` + `await db.refresh(...)` + `serialize_project(...)`,响应补充 `doc_count`、`user_role`。
- 项目角色判定统一走 `normalize_project_role`(历史数据角色名为大写),修复分享/仪表盘/项目接口对同一用户判定不一致的问题;`projects.py` 中重复的 `check_project_access` 死代码删除。
- 新增回归用例覆盖角色归一化与项目读权限。
### 4. 仓库与运维整理
- **脚本统一迁入 `scripts/`**:`deploy.sh`(路径变更)+ **新增** `start.sh` 一键启动(依赖检查 / 后端 venv / 前端 vite / `/health` 探活)+ 新增 `stop.sh`;`scripts/README.md` 说明用途。
- 删除 19 个与新 schema 脱节的一次性 SQL/Python 脚本、删除针对旧 compose 结构的 `fix_docker_deployment.sh`。
- `docker-compose.yml` 去除废弃 `version` 字段与无效的 `VITE_API_BASE_URL` 构建参数;`.gitignore` 补 `backups/`、`.run/`;`deploy.sh uninstall` 行为修正。
- 新增 `backend/requirements-dev.txt` 与 `backend/pytest.ini`,使后端测试可一条命令运行。
- 删除根目录里与本项目无关的历史残留 `.dsh-plugins/pet-dock/`(第三方桌面 shell 插件,仓库内无任何引用);`.gitignore` 补 `.claude/`。
### 4b. 配置与安全加固
- `docker-compose.yml` 补 `DEFAULT_USER_PASSWORD` 透传,`.env.example` 补齐该键 —— 此前 Docker 部署下改 `.env` **完全无效**,新建用户恒为 `User@123456`。
- 新增启动期「安全自检」`Settings.security_warnings()`:`SECRET_KEY` 命中模板占位值或长度 < 16、`DEFAULT_USER_PASSWORD` 仍为仓库默认值时打 `WARNING`(**只告警不阻断**,兼容存量部署)。此前漏配 `.env` 会带着**公开已知的 JWT 密钥**静默上线。
- `.env.example` 的 `ADMIN_EMAIL` 由真实域名邮箱改为 `admin@example.com`,与 compose / `init_db.py` 默认值对齐。
- `scripts/start.sh` 新增 `--daemon`:健康检查通过后立即返回(默认仍为前台驻留 + Ctrl+C 停止),使一键启动可被 CI / 上层脚本调用。
### 5. 文档体系重组(`docs/`)
- 根目录仅保留 `README.md` + `docker-compose.yml` + `.env.example` + `.gitignore`/`.dockerignore`,文档全部归入 `docs/`。
- 重写:根 `README.md`、`docs/README.md`(总入口)、`docs/quickstart.md`、`docs/database.md`、`docs/deploy/README.md`、`docs/deploy/changelog.md`、`backend/README.md`、`docs/manual/user-guide.md`(13 章用户手册,替代原中文命名手册)。
- 归档:`PROJECT.md`、`IMPLEMENTATION_PLAN.md` → `docs/archive/`(含登记表 `docs/archive/README.md`);删除内容重复且过期的 `DEPLOYEE.md`、`README_DOCKER.md`。
- 清理文档中的明文生产凭据;修复失效链接与**与代码不符的描述**:健康检查统一为 `GET /health`(不存在 `/api/v1/health`)、OpenAPI 实际路径是 `/openapi.json` 而非 `/api/v1/openapi.json`、`APP_NAME`/`ADMIN_EMAIL` 默认值回填、`app/utils` 目录并不存在等。
## 能力覆盖(DV 矩阵)
| DV | 单元 | v1.0.0 影响 |
| --- | --- | --- |
| DV-0001 | 认证与会话 | 无功能变更;`/health` 探活口径在文档中统一 |
| DV-0002 | 项目与文件系统 | `get_project` 500 修复、角色归一化、响应补 `doc_count`/`user_role` |
| DV-0003 | 文档编辑与文件操作 | **重点变更**:默认编辑区 + 工具栏内三态切换 + 源码 TOC + 未保存保护 + Ctrl/Cmd+S + 全屏层级修复 |
| DV-0004 | RBAC 与系统管理 | 角色大小写归一化,读权限判定统一 |
| DV-0005 | 全文检索 | 无功能变更;UI 令牌化 |
| DV-0006 | ZVec 向量化 | 无功能变更;`backend/models/`(本地向量模型)明确 gitignore |
| DV-0007 | 知识库 RAG 对话 | UI 令牌化、Toast 反馈、SSE 约定写入 `backend/README.md` |
| DV-0008 | LLM 模型配置 | UI 令牌化;`api_key` 明文存储列为已知问题 |
| DV-0009 | 分享与公开预览 | 角色归一化影响分享权限判定 |
| DV-0010 | 通知/日志/Git/导出 | 模型注册修复;UI 令牌化 |
| DV-0011 | MCP 接入 | 无功能变更;接入说明见 `docs/sdd/integrations/mcp.md` |
## 验证边界(本发布实际做过什么)
### 已验证(可复验)
| 项 | 命令 / 方法 | 结果 |
| --- | --- | --- |
| 后端单测 | `cd backend && ./venv/bin/python -m pytest` | **40 passed**,10 warnings(均为 Pydantic `class Config` 弃用告警) |
| 前端静态检查 | `cd frontend && npm run lint` | **0 error / 25 warning**(全部为 `react-hooks/exhaustive-deps`) |
| 前端构建 | `cd frontend && npm run build` | 通过;产物见下方「已知问题」的体积表 |
| 编辑器三态 + TOC | 浏览器实测(headless Chrome + CDP,1680×980,临时项目,用后已清理) | 默认 `编辑` 且 `.bytemd-preview{display:none}`;分栏 641/641;预览模式下左栏 Markdown 工具栏隐藏;TOC 来自源码大纲,编辑模式下点条目光标跳到目标行;切换文件(编辑器重挂载)后分段控件仍在;超大文档(>250000 字符)与 PDF 不出现分段控件 |
| 工具栏三态切换与全屏同排 | 同上 | 工具栏右侧顺序:`编辑/分栏/预览`(88×24)→ 全屏图标(24×24),垂直中心差 0px,工具栏高 33px 无重叠;≥1100px 视口不换行;明/暗主题配色一致 |
| 全屏层级与 `Esc` 退出 | 同上 | `.bytemd-fullscreen` `z-index:900` + `fixed` 覆盖 1680×980;`.mode-switch`(`z-index:1`)被完全压住(`elementFromPoint` 落在编辑器内);`Esc` 两次均退出并恢复布局 |
| 未保存保护 | 同上 | 输入 → 「未保存」标记出现;`Cmd+S` → 保存成功且标记消失;不脏时返回不弹框;脏时弹框且「继续编辑」保持现场 |
| 项目详情接口 | `GET /api/v1/projects/{id}` | 有权 200(含 `doc_count`/`user_role`)、不存在 404、他人私有 403,与 `/files/{id}/tree` 口径一致 |
| 模型注册 | 遍历 `Base.metadata.tables` 与线上库双向 diff | 18 张表,差异为空 |
| 本地一键启动 | `./scripts/start.sh --daemon` → `curl /health` → `./scripts/stop.sh` | 脚本返回 0;后端 `GET /health` healthy、前端 `:5173` 200、`.run/*.pid` 保留 |
| 安全自检 | `tests/test_security_selfcheck.py` | 占位/短 `SECRET_KEY`、默认 `DEFAULT_USER_PASSWORD` 均被告警;加固后的配置零告警 |
| 文档链接完整性 | 全仓 Markdown 相对链接检查(排除 `storage/`、`venv`、`models/`) | 0 断链(归档内 `_assets/*.png` 为预期缺图) |
| 凭据泄露扫描 | 全仓明文口令扫描 | 仅剩 `.env.example` / compose / `deploy.sh` 的模板默认值(`.env.example` 内的真实域名邮箱已改占位) |
| 文档命令/路径可执行性 | 逐条比对脚本与端点:`GET /`、`/health`、`/docs`、`/openapi.json`、`deploy.sh --help` 的子命令表、`start.sh --help` | 一致(`GET /` 返回 `version=1.0.0`;`/api/v1/health` 与 `/api/v1/openapi.json` 均不存在,文档已修正) |
### 未验证(发布风险,需在上线前补做)
- **Docker 端到端**:本机 Docker daemon 不可用,`docker compose up -d --build` + `./scripts/deploy.sh upgrade` 本发布**未实测**,只做静态审查(Dockerfile / nginx.conf / compose 已逐行核对,结论见 OI-5/OI-13/OI-14)。上线前必须演练一次:storage bind mount、SSE 流式对话、`/mcp` 反代、`init_db.py` 在空库上建表。
- **MCP 真实客户端接入**:仅有接口级验证,未用真实 MCP 客户端跑通 DV-0011 全链路。
- **Git 仓库同步真实远端**:`project_git_repos` 的 push/pull 未在真实远端验证(依赖外部环境凭据)。
- **多浏览器 / 移动端适配**:仅在 Chromium 内核验证;Safari/Firefox 与窄屏未测。
- **升级回归**:本发布无 DB 迁移,但未在「旧库 + 新代码」上做过完整冒烟(共享开发库含真实数据,禁止写测)。
## 已知问题(开放项)
| 编号 | 优先级 | 内容 | 建议处置 |
| --- | --- | --- | --- |
| OI-P0 | **P0(阻塞上线)** | 远端 Redis(`192.168.124.203`)`rdb_last_bgsave_status=err`,最近一次成功 RDB 已距今数小时,当前靠 `stop-writes-on-bgsave-error=no` 临时放开写入。该参数**非持久配置**,Redis 重启后恢复默认 → 全量写入返回 MISCONF | 上线前由运维排查磁盘/`fork` 内存并恢复 RDB;上线前把该参数写入正式配置或彻底修复 bgsave |
| OI-1 | P1 | 敏感信息明文入库:`share_links.access_pass`、`project_git_repos.token`、`llm_model_config.api_key`(详情接口回传原文) | 下一版本引入对称加密/KMS + 掩码回显;先限制详情接口权限 |
| OI-3 | P1 | 前端 **0 自动化测试**;后端仅 40 个用例,权限/文件系统覆盖薄 | 建 vitest + testing-library 基线,优先覆盖 DV-0002/0003/0004 |
| OI-5 | P1 | `frontend/Dockerfile` 中 `rm -rf package-lock.json && npm install` 导致前端构建不可重现。**根因已定位**:`package-lock.json` 在 macOS/arm64 上生成,`packages` 里只有 `@rollup/rollup-darwin-arm64`,**缺少 `@rollup/rollup-linux-x64-musl`**,直接 `npm ci` 会在 Alpine 镜像内因 rollup 缺原生二进制而失败(脚本注释提到的正是这个 bug) | 用 `npm install --cpu=x64 --os=linux --include=optional`(或 `npm install --package-lock-only --force`)重生成 lockfile 并验证含 linux-musl 条目,再切 `npm ci`;或把构建阶段换成 glibc 基础镜像。**未做**:本发布无法验证 Docker 构建,故保持现状不改 |
| OI-6 | P2 | 前端产物偏大:`antd` 1302 kB(gzip 408)、`index` 1242 kB(gzip 387)、`index` 919 kB(gzip 301)、`index` 522 kB(gzip 161) | 按需引入 antd 图标/组件、拆分 ByteMD 相关 chunk、提高 `manualChunks` 粒度 |
| OI-7 | P2 | 超大组件:`DocumentEditor` 1724 行、`DocumentPage` 1620 行、`Chat` 1638 行、`ProjectList` 1501 行 | 按 DV 切片拆分子组件与自定义 hook |
| OI-8 | P2 | eslint 25 warning(全部为 `react-hooks/exhaustive-deps`)。原 `ProjectList.jsx` 成员弹窗里 4 处输出成员/用户列表的 `console.log` 已删除,`notifications.py` 4 处 `print()` 降级日志已改 `logger`(`console.log`/`print()` 全仓归零) | 逐个消解 exhaustive-deps,CI 开启 `--max-warnings 0` |
| OI-9 | P2 | 重复实现:`shares.py` 内本地 `get_project_or_404`;`generate_share_code` 在 `projects.py` 与 `shares.py` 各一份;`api/knowledgeBase.js` 命名遗留 | 收敛到 `project_service` 与统一命名 |
| OI-10 | P2 | 配置键名两套体系:`backend/.env`(`STORAGE_ROOT/PROJECTS_PATH/USERS_PATH/TEMP_PATH`)与根 `.env`(`STORAGE_PATH`) | 下一版本统一并保留一个发布周期的兼容读取 |
| OI-11 | P2 | 超级管理员访问他人私有项目返回 403(全站口径已一致),但仪表盘提供全局统计 | 需产品明确超管边界(数据面 vs 管理面),再决定是否放开 |
| OI-12 | P3 | Pydantic `class Config` 弃用告警 10 处;磁盘遗留 `storage/projects_bak`(61M)、`backup/nex_docus_20260311.sql`(均已 gitignore) | 迁 `model_config`;离线归档后删除磁盘垃圾 |
| OI-13 | P1 | `docker-compose.yml` 默认把 **MySQL `3306` 与 Redis `6379` 发布到宿主机所有网卡**(`${MYSQL_PORT:-3306}:3306`)。配合默认口令/占位 `SECRET_KEY`,等同于把数据库直接暴露到局域网 | 默认改绑回环:`"127.0.0.1:${MYSQL_PORT:-3306}:3306"`;确需远程访问时用 `.env` 显式声明监听地址,并在主机防火墙拦截 |
| OI-14 | P2 | MySQL 数据目录挂在 `${STORAGE_PATH}/mysql`,而 `${STORAGE_PATH}` 又整体 bind mount 进后端容器当作文档存储根(`STORAGE_ROOT=/data/nex_docus_store`)→ 后端容器内可见数据库文件,且 `deploy.sh backup` 打包 storage 时会连正在写入的 datadir 一起归档 | 数据库与文件存储分目录(如 `${DATA_PATH}/mysql`),备份脚本显式排除 datadir |
| OI-15 | P2 | compose 里 `SECRET_KEY`/`ADMIN_PASSWORD`/`DB_PASSWORD` 等都有**公开已知的兜底默认值**,漏配 `.env` 时静默使用。本发布已加启动告警,但仍属"默认可用即不安全" | 下一版本移除敏感项兜底值,缺配即启动失败(fail-fast) |
## 上线清单(发布执行顺序)
1. 按提交划分合并改动(编辑器 / UI 规范 / 后端修复 / 脚本 / 文档 / 配置 / 测试)。
2. `git tag -a v1.0.0 -m "NEX Docus v1.0.0" && git push origin v1.0.0`。
3. **先解决 OI-P0(Redis RDB)**,再执行部署。
4. Docker 演练:`./scripts/deploy.sh upgrade` → 登录 → 打开文档编辑 → AI 问答 → 分享预览。
5. **立即修改默认管理员密码**(两套初始口令见[部署变更日志](../../deploy/changelog.md#5-两套默认密码并存重要易踩坑)),并在 `.env` 显式设置:
`ADMIN_PASSWORD=<随机值>`、`DEFAULT_USER_PASSWORD=<随机值>`、`SECRET_KEY=$(openssl rand -hex 32)`。
6. 确认 `GET /` 返回 `version=1.0.0`,并且 `docker compose logs backend | grep 安全自检` **无输出**(有输出说明第 5 步没做完)。
7. 按 OI-13 把 MySQL/Redis 端口绑到 `127.0.0.1`(或确认主机防火墙已拦截)。

View File

@ -4,7 +4,7 @@
涉及 `auth.py`(API)、`security.py`(JWT/bcrypt)、`deps.py`(依赖注入)、`redis_client.py`(TokenCache)。
## 数据模型
- `users`(id/username/password_hash/…)——细节见 DATABASE.md。
- `users`(id/username/password_hash/…)——细节见 [`docs/database.md`](../../../database.md)。
## 接口设计
- POST /api/v1/auth/{register,login,logout}

View File

@ -7,7 +7,7 @@
- `projects`(storage_key/owner_id/status/access_pass…)
- `project_members`(role: admin/editor/viewer)
- `document_meta`、`document_vector`
- 细节见 DATABASE.md。
- 细节见 [`docs/database.md`](../../../database.md)。
## 存储结构
`STORAGE_ROOT/projects/<storage_key>/` + `_assets/images|files` + 默认 README.md。

View File

@ -5,7 +5,7 @@
## 数据模型
- `roles`、`user_roles`、`system_menus`、`role_menus`。
- 细节见 DATABASE.md。
- 细节见 [`docs/database.md`](../../../database.md)。
## 接口设计
- /api/v1/roles/*、/api/v1/role-permissions/*

View File

@ -7,7 +7,7 @@
- 代码/模块组织。
## 数据模型
- 涉及表/字段(指向 DATABASE.md 细节)。
- 涉及表/字段(指向 [`docs/database.md`](../../../database.md) 细节)。
## 接口设计
- API 端点 / 交互。

View File

@ -1,29 +0,0 @@
#!/bin/bash
# Fix for "KeyError: 'ContainerConfig'" in legacy docker-compose
echo "Cleaning up Docker resources to fix deployment error..."
# 1. Stop containers and remove orphans
echo "Step 1: Stopping containers..."
if command -v docker-compose &> /dev/null; then
docker-compose down --remove-orphans
elif docker compose version &> /dev/null; then
docker compose down --remove-orphans
fi
# 2. Remove the frontend image explicitly to force metadata refresh
echo "Step 2: Removing frontend image..."
docker rmi nex-docus-frontend 2>/dev/null || true
# Also remove the tagged image if it exists differently (based on docker-compose.yml naming)
docker images | grep nexdocus-frontend | awk '{print $3}' | xargs -r docker rmi
# 3. Prune dangling images which might cause confusion
echo "Step 3: Pruning dangling images..."
docker image prune -f
echo "Cleanup complete."
echo "You can now try deploying again with:"
echo " docker-compose up -d --build"
echo " OR"
echo " ./deploy.sh upgrade"

View File

@ -0,0 +1,29 @@
module.exports = {
root: true,
env: { browser: true, es2021: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: { jsx: true },
},
settings: { react: { version: 'detect' } },
ignorePatterns: ['dist', 'node_modules', 'public'],
overrides: [
{
files: ['vite.config.js'],
env: { node: true, es2022: true },
},
],
rules: {
'react/prop-types': 'off',
'react/no-unescaped-entities': 'off',
'no-unused-vars': ['warn', { args: 'none', caughtErrors: 'none' }],
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
}

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,14 @@
{
"name": "nex-docus-frontend",
"private": true,
"version": "0.9.9",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
"lint": "eslint . --ext js,jsx",
"lint:strict": "eslint . --ext js,jsx --max-warnings 0"
},
"dependencies": {
"@ant-design/icons": "^5.2.6",
@ -42,16 +43,15 @@
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-legacy": "^5.4.3",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"terser": "^5.46.0",
"vite": "^5.0.8"
},
"engines": {
"node": ">=18"
}
}

View File

@ -1,33 +1,38 @@
import { useEffect } from 'react'
import { lazy, Suspense, useEffect } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useParams, Outlet } from 'react-router-dom'
import { ConfigProvider, theme } from 'antd'
import { App as AntdApp, ConfigProvider, theme } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import useThemeStore from '@/stores/themeStore'
import FeedbackBridge from '@/components/Feedback/FeedbackBridge'
import PageLoading from '@/components/PageLoading/PageLoading'
import { getAntdTheme } from '@/theme/antdTheme'
import Login from '@/pages/Login/Login'
import ProjectList from '@/pages/ProjectList/ProjectList'
import DocumentPage from '@/pages/Document/DocumentPage'
import DocumentEditor from '@/pages/Document/DocumentEditor'
import Dashboard from '@/pages/Dashboard'
import Desktop from '@/pages/Desktop'
import Constructing from '@/pages/Constructing'
import ProjectSharePage from '@/pages/Preview/ProjectSharePage'
import FileSharePage from '@/pages/Preview/FileSharePage'
import ProfilePage from '@/pages/Profile/ProfilePage'
import Permissions from '@/pages/System/Permissions'
import Users from '@/pages/System/Users'
import Roles from '@/pages/System/Roles'
import ModelConfigs from '@/pages/System/ModelConfigs'
import SystemLogs from '@/pages/SystemLogs/SystemLogs'
import Chat from '@/pages/Chat/Chat'
import NotificationList from '@/pages/Notifications/NotificationList'
import ProtectedRoute from '@/components/ProtectedRoute'
import MainLayout from '@/components/MainLayout/MainLayout'
import ProtectedRoute from '@/components/ProtectedRoute'
import '@/App.css'
dayjs.locale('zh-cn')
// 路由级懒加载:首屏只加载登录页与布局,其余页面按需加载
const ProjectList = lazy(() => import('@/pages/ProjectList/ProjectList'))
const DocumentPage = lazy(() => import('@/pages/Document/DocumentPage'))
const DocumentEditor = lazy(() => import('@/pages/Document/DocumentEditor'))
const Dashboard = lazy(() => import('@/pages/Dashboard'))
const Desktop = lazy(() => import('@/pages/Desktop'))
const Constructing = lazy(() => import('@/pages/Constructing'))
const ProjectSharePage = lazy(() => import('@/pages/Preview/ProjectSharePage'))
const FileSharePage = lazy(() => import('@/pages/Preview/FileSharePage'))
const ProfilePage = lazy(() => import('@/pages/Profile/ProfilePage'))
const Permissions = lazy(() => import('@/pages/System/Permissions'))
const Users = lazy(() => import('@/pages/System/Users'))
const Roles = lazy(() => import('@/pages/System/Roles'))
const ModelConfigs = lazy(() => import('@/pages/System/ModelConfigs'))
const SystemLogs = lazy(() => import('@/pages/SystemLogs/SystemLogs'))
const Chat = lazy(() => import('@/pages/Chat/Chat'))
const NotificationList = lazy(() => import('@/pages/Notifications/NotificationList'))
// 重定向到文档页面的组件
function RedirectToDocs() {
const { projectId } = useParams()
@ -47,11 +52,7 @@ function App() {
const { isDarkMode } = useThemeStore()
useEffect(() => {
if (isDarkMode) {
document.body.classList.add('dark')
} else {
document.body.classList.remove('dark')
}
document.body.classList.toggle('dark', isDarkMode)
}, [isDarkMode])
return (
@ -59,9 +60,17 @@ function App() {
locale={zhCN}
theme={{
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
...getAntdTheme(isDarkMode),
}}
>
<AntdApp
component={false}
message={{ maxCount: 3 }}
notification={{ placement: 'topRight', top: 24, duration: 3, maxCount: 3 }}
>
<FeedbackBridge>
<BrowserRouter>
<Suspense fallback={<PageLoading />}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/share/project/:shareCode" element={<ProjectSharePage />} />
@ -93,7 +102,10 @@ function App() {
<Route path="/" element={<Navigate to="/projects" replace />} />
</Routes>
</Suspense>
</BrowserRouter>
</FeedbackBridge>
</AntdApp>
</ConfigProvider>
)
}

View File

@ -83,7 +83,7 @@ export const sendChatMessageStream = async (sessionId, message, handlers = {}) =
}
}
while (true) {
for (;;) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })

View File

@ -1,264 +0,0 @@
/* 帮助面板样式 */
.action-help-panel .ant-drawer-header {
border-bottom: 2px solid var(--border-color);
}
.help-panel-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 16px;
font-weight: 600;
}
.help-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.help-panel-header-text {
font-weight: 500;
color: rgba(0, 0, 0, 0.88);
}
/* 操作详情样式 */
.help-action-detail {
display: flex;
flex-direction: column;
gap: 20px;
}
.help-action-header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
color: white;
}
.help-action-icon {
font-size: 28px;
line-height: 1;
opacity: 0.95;
}
.help-action-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.help-action-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: white;
}
.help-action-badge {
align-self: flex-start;
margin: 0;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
}
/* 帮助区块样式 */
.help-section {
padding: 16px;
background: var(--bg-color-secondary);
border-radius: 8px;
border-left: 3px solid #1677ff;
}
.help-section-warning {
background: #fff7e6;
border-left-color: #faad14;
}
.help-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--text-color);
margin-bottom: 12px;
}
.help-section-content {
font-size: 13px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.65);
}
.help-section-list {
margin: 0;
padding-left: 20px;
list-style-type: disc;
}
.help-section-list li {
font-size: 13px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.65);
margin-bottom: 8px;
}
.help-section-list li:last-child {
margin-bottom: 0;
}
.help-section-steps {
margin: 0;
padding-left: 20px;
counter-reset: step-counter;
list-style: none;
}
.help-section-steps li {
font-size: 13px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.65);
margin-bottom: 12px;
padding-left: 12px;
position: relative;
counter-increment: step-counter;
}
.help-section-steps li:before {
content: counter(step-counter);
position: absolute;
left: -20px;
top: 0;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: #1677ff;
color: white;
border-radius: 50%;
font-size: 11px;
font-weight: 600;
}
.help-section-steps li:last-child {
margin-bottom: 0;
}
.help-shortcut {
display: inline-block;
}
.help-shortcut kbd {
display: inline-block;
padding: 6px 12px;
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
border: 1px solid var(--border-color-strong);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
font-size: 12px;
font-family: 'Monaco', 'Consolas', monospace;
color: rgba(0, 0, 0, 0.88);
font-weight: 500;
}
/* 操作列表样式 */
.help-actions-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.help-action-item {
padding: 12px;
background: white;
border: 1px solid var(--border-color);
border-radius: 8px;
transition: all 0.3s ease;
cursor: pointer;
}
.help-action-item:hover {
border-color: #1677ff;
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.1);
transform: translateY(-2px);
}
.help-action-item-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.help-action-item-icon {
font-size: 16px;
color: #1677ff;
}
.help-action-item-title {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--text-color);
}
.help-action-item-shortcut {
padding: 2px 6px;
background: var(--bg-color-secondary);
border: 1px solid var(--border-color-strong);
border-radius: 4px;
font-size: 11px;
font-family: 'Monaco', 'Consolas', monospace;
color: var(--text-color-secondary);
}
.help-action-item-desc {
font-size: 12px;
line-height: 1.6;
color: var(--text-color-secondary);
padding-left: 24px;
}
/* 折叠面板自定义样式 */
.action-help-panel .ant-collapse-ghost > .ant-collapse-item {
margin-bottom: 16px;
}
.action-help-panel .ant-collapse-ghost > .ant-collapse-item > .ant-collapse-header {
padding: 12px 16px;
background: var(--bg-color-secondary);
border-radius: 8px;
font-weight: 500;
}
body.dark .help-section-warning {
background: rgba(250, 173, 20, 0.12);
border-left-color: #faad14;
}
.action-help-panel .ant-collapse-ghost > .ant-collapse-item > .ant-collapse-content {
padding-top: 12px;
}
/* 响应式调整 */
@media (max-width: 768px) {
.action-help-panel .ant-drawer-content-wrapper {
width: 100% !important;
}
.help-action-header {
padding: 12px;
}
.help-section {
padding: 12px;
}
}

View File

@ -1,228 +0,0 @@
import { useState, useEffect } from 'react'
import { Drawer, Collapse, Badge, Tag, Empty } from 'antd'
import {
QuestionCircleOutlined,
BulbOutlined,
WarningOutlined,
InfoCircleOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'
import './ActionHelpPanel.css'
const { Panel } = Collapse
/**
* 操作帮助面板组件
* 在页面侧边显示当前操作的详细说明和帮助信息
* @param {Object} props
* @param {boolean} props.visible - 是否显示面板
* @param {Function} props.onClose - 关闭回调
* @param {Object} props.currentAction - 当前操作信息
* @param {Array} props.allActions - 所有可用操作列表
* @param {string} props.placement - 面板位置
* @param {Function} props.onActionSelect - 选择操作的回调
*/
function ActionHelpPanel({
visible = false,
onClose,
currentAction = null,
allActions = [],
placement = 'right',
onActionSelect,
}) {
const [activeKey, setActiveKey] = useState(['current'])
// 当 currentAction 变化时,自动展开"当前操作"面板
useEffect(() => {
if (currentAction && visible) {
setActiveKey(['current'])
}
}, [currentAction, visible])
// 渲染当前操作详情
const renderCurrentAction = () => {
if (!currentAction) {
return (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="将鼠标悬停在按钮上查看帮助"
style={{ padding: '40px 0' }}
/>
)
}
return (
<div className="help-action-detail">
{/* 操作标题 */}
<div className="help-action-header">
<div className="help-action-icon">{currentAction.icon}</div>
<div className="help-action-info">
<h3 className="help-action-title">{currentAction.title}</h3>
{currentAction.badge && (
<Tag color={currentAction.badge.color} className="help-action-badge">
{currentAction.badge.text}
</Tag>
)}
</div>
</div>
{/* 操作描述 */}
{currentAction.description && (
<div className="help-section">
<div className="help-section-title">
<InfoCircleOutlined /> 功能说明
</div>
<div className="help-section-content">{currentAction.description}</div>
</div>
)}
{/* 使用场景 */}
{currentAction.scenarios && currentAction.scenarios.length > 0 && (
<div className="help-section">
<div className="help-section-title">
<BulbOutlined /> 使用场景
</div>
<ul className="help-section-list">
{currentAction.scenarios.map((scenario, index) => (
<li key={index}>{scenario}</li>
))}
</ul>
</div>
)}
{/* 操作步骤 */}
{currentAction.steps && currentAction.steps.length > 0 && (
<div className="help-section">
<div className="help-section-title">
<ThunderboltOutlined /> 操作步骤
</div>
<ol className="help-section-steps">
{currentAction.steps.map((step, index) => (
<li key={index}>{step}</li>
))}
</ol>
</div>
)}
{/* 注意事项 */}
{currentAction.warnings && currentAction.warnings.length > 0 && (
<div className="help-section help-section-warning">
<div className="help-section-title">
<WarningOutlined /> 注意事项
</div>
<ul className="help-section-list">
{currentAction.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
{/* 快捷键 */}
{currentAction.shortcut && (
<div className="help-section">
<div className="help-section-title">⌨️ 快捷键</div>
<div className="help-shortcut">
<kbd>{currentAction.shortcut}</kbd>
</div>
</div>
)}
{/* 权限要求 */}
{currentAction.permission && (
<div className="help-section">
<div className="help-section-title">🔐 权限要求</div>
<div className="help-section-content">
<Tag color="blue">{currentAction.permission}</Tag>
</div>
</div>
)}
</div>
)
}
// 渲染所有操作列表
const renderAllActions = () => {
if (allActions.length === 0) {
return <Empty description="暂无操作" />
}
return (
<div className="help-actions-list">
{allActions.map((action, index) => (
<div
key={index}
className="help-action-item"
onClick={() => {
if (onActionSelect) {
onActionSelect(action)
setActiveKey(['current'])
}
}}
>
<div className="help-action-item-header">
<span className="help-action-item-icon">{action.icon}</span>
<span className="help-action-item-title">{action.title}</span>
{action.shortcut && (
<kbd className="help-action-item-shortcut">{action.shortcut}</kbd>
)}
</div>
<div className="help-action-item-desc">{action.description}</div>
</div>
))}
</div>
)
}
return (
<Drawer
title={
<div className="help-panel-title">
<QuestionCircleOutlined style={{ marginRight: 8 }} />
操作帮助
{currentAction && <Badge status="processing" text="实时帮助" />}
</div>
}
placement={placement}
width={420}
open={visible}
onClose={onClose}
className="action-help-panel"
>
<Collapse
activeKey={activeKey}
onChange={setActiveKey}
ghost
expandIconPosition="end"
>
<Panel
header={
<div className="help-panel-header">
<span className="help-panel-header-text">当前操作</span>
{currentAction && (
<Badge
count="实时"
style={{
backgroundColor: '#52c41a',
fontSize: 10,
height: 18,
lineHeight: '18px',
}}
/>
)}
</div>
}
key="current"
>
{renderCurrentAction()}
</Panel>
<Panel header="所有可用操作" key="all">
{renderAllActions()}
</Panel>
</Collapse>
</Drawer>
)
}
export default ActionHelpPanel

View File

@ -1,304 +0,0 @@
/* 底部提示栏基础样式 */
.bottom-hint-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9999;
padding: 12px 24px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* 主题样式 */
.bottom-hint-bar-light {
background: #ffffff;
border-top: 1px solid var(--border-color);
}
.bottom-hint-bar-dark {
background: #001529;
color: #ffffff;
}
.bottom-hint-bar-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff;
}
/* 容器布局 */
.hint-bar-container {
display: flex;
align-items: center;
gap: 24px;
max-width: 1400px;
margin: 0 auto;
}
/* 左侧区域 */
.hint-bar-left {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.hint-bar-icon {
font-size: 24px;
opacity: 0.9;
}
.bottom-hint-bar-light .hint-bar-icon {
color: #1677ff;
}
.hint-bar-title-section {
display: flex;
flex-direction: column;
gap: 4px;
}
.hint-bar-title {
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
}
.bottom-hint-bar-light .hint-bar-title {
color: rgba(0, 0, 0, 0.88);
}
.hint-bar-badge {
margin: 0;
font-size: 10px;
padding: 1px 6px;
align-self: flex-start;
}
/* 中间区域 */
.hint-bar-center {
flex: 1;
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.hint-bar-description,
.hint-bar-quick-tip,
.hint-bar-warning {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
line-height: 1.4;
}
.bottom-hint-bar-light .hint-bar-description,
.bottom-hint-bar-light .hint-bar-quick-tip {
color: rgba(0, 0, 0, 0.65);
}
.hint-info-icon {
font-size: 14px;
opacity: 0.8;
}
.bottom-hint-bar-light .hint-info-icon {
color: #1677ff;
}
.hint-tip-icon {
font-size: 14px;
color: #fadb14;
}
.hint-warning-icon {
font-size: 14px;
color: #ff7a45;
}
.bottom-hint-bar-light .hint-bar-warning {
color: #d46b08;
}
/* 右侧区域 */
.hint-bar-right {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.hint-bar-shortcut {
display: flex;
align-items: center;
gap: 8px;
}
.shortcut-label {
font-size: 11px;
opacity: 0.7;
}
.shortcut-kbd {
display: inline-block;
padding: 4px 10px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
font-size: 11px;
font-family: 'Monaco', 'Consolas', monospace;
color: inherit;
font-weight: 500;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.bottom-hint-bar-light .shortcut-kbd {
background: #f0f0f0;
border-color: var(--border-color-strong);
color: rgba(0, 0, 0, 0.88);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
}
.hint-bar-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: inherit;
cursor: pointer;
transition: all 0.3s ease;
}
.hint-bar-close:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.bottom-hint-bar-light .hint-bar-close {
background: #f0f0f0;
border-color: var(--border-color-strong);
color: rgba(0, 0, 0, 0.45);
}
.bottom-hint-bar-light .hint-bar-close:hover {
background: #e0e0e0;
color: rgba(0, 0, 0, 0.88);
}
/* 进度指示条 */
.hint-bar-progress {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: rgba(255, 255, 255, 0.3);
overflow: hidden;
}
.hint-bar-progress::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.6);
animation: progressWave 3s ease-in-out infinite;
}
.bottom-hint-bar-light .hint-bar-progress {
background: #f0f0f0;
}
.bottom-hint-bar-light .hint-bar-progress::after {
background: #1677ff;
}
@keyframes progressWave {
0%, 100% {
transform: translateX(-100%);
}
50% {
transform: translateX(0);
}
}
/* 响应式调整 */
@media (max-width: 1024px) {
.hint-bar-container {
flex-wrap: wrap;
gap: 12px;
}
.hint-bar-center {
flex-basis: 100%;
order: 3;
gap: 12px;
}
.hint-bar-description,
.hint-bar-quick-tip,
.hint-bar-warning {
font-size: 12px;
}
}
@media (max-width: 768px) {
.bottom-hint-bar {
padding: 10px 16px;
}
.hint-bar-left {
gap: 8px;
}
.hint-bar-icon {
font-size: 20px;
}
.hint-bar-title {
font-size: 14px;
}
.hint-bar-right {
gap: 8px;
}
.shortcut-label {
display: none;
}
.hint-bar-close {
width: 24px;
height: 24px;
}
}
@media (max-width: 480px) {
.hint-bar-quick-tip {
display: none;
}
.hint-bar-warning {
flex-basis: 100%;
}
}

View File

@ -1,90 +0,0 @@
import { Tag } from 'antd'
import {
InfoCircleOutlined,
BulbOutlined,
WarningOutlined,
CloseOutlined,
} from '@ant-design/icons'
import './BottomHintBar.css'
/**
* 底部固定提示栏组件
* 在页面底部显示当前悬停按钮的实时说明
* @param {Object} props
* @param {boolean} props.visible - 是否显示提示栏
* @param {Object} props.hintInfo - 当前提示信息
* @param {Function} props.onClose - 关闭回调
* @param {string} props.theme - 主题:light, dark, gradient
*/
function BottomHintBar({ visible = false, hintInfo = null, onClose, theme = 'gradient' }) {
if (!visible || !hintInfo) return null
return (
<div
className={`bottom-hint-bar bottom-hint-bar-${theme}`}
onMouseEnter={(e) => e.stopPropagation()}
>
<div className="hint-bar-container">
{/* 左侧:图标和标题 */}
<div className="hint-bar-left">
<div className="hint-bar-icon">{hintInfo.icon}</div>
<div className="hint-bar-title-section">
<h4 className="hint-bar-title">{hintInfo.title}</h4>
{hintInfo.badge && (
<Tag color={hintInfo.badge.color} className="hint-bar-badge">
{hintInfo.badge.text}
</Tag>
)}
</div>
</div>
{/* 中间:主要信息 */}
<div className="hint-bar-center">
{/* 描述 */}
{hintInfo.description && (
<div className="hint-bar-description">
<InfoCircleOutlined className="hint-info-icon" />
<span>{hintInfo.description}</span>
</div>
)}
{/* 快速提示 */}
{hintInfo.quickTip && (
<div className="hint-bar-quick-tip">
<BulbOutlined className="hint-tip-icon" />
<span>{hintInfo.quickTip}</span>
</div>
)}
{/* 警告 */}
{hintInfo.warning && (
<div className="hint-bar-warning">
<WarningOutlined className="hint-warning-icon" />
<span>{hintInfo.warning}</span>
</div>
)}
</div>
{/* 右侧:快捷键和关闭 */}
<div className="hint-bar-right">
{hintInfo.shortcut && (
<div className="hint-bar-shortcut">
<span className="shortcut-label">快捷键</span>
<kbd className="shortcut-kbd">{hintInfo.shortcut}</kbd>
</div>
)}
{onClose && (
<button className="hint-bar-close" onClick={onClose}>
<CloseOutlined />
</button>
)}
</div>
</div>
{/* 进度指示条 */}
<div className="hint-bar-progress" />
</div>
)
}
export default BottomHintBar

View File

@ -1,201 +0,0 @@
/* 按钮带引导 - 简洁现代设计 */
.button-with-guide {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* 帮助图标按钮 - 简洁扁平设计 */
.guide-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: rgba(0, 0, 0, 0.35);
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.guide-icon-btn:hover {
background: rgba(22, 119, 255, 0.06);
color: #1677ff;
}
.guide-icon-btn:active {
background: rgba(22, 119, 255, 0.12);
}
/* 引导弹窗样式 */
.button-guide-modal .ant-modal-header {
padding: 20px 24px;
border-bottom: 2px solid var(--border-color);
}
.button-guide-modal .ant-modal-body {
padding: 24px;
max-height: 600px;
overflow-y: auto;
}
.guide-modal-header {
display: flex;
align-items: center;
gap: 12px;
}
.guide-modal-icon {
font-size: 24px;
color: #1677ff;
}
.guide-modal-title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.guide-modal-badge {
margin: 0;
font-size: 11px;
padding: 2px 8px;
}
/* 引导区块样式 */
.guide-section {
margin-bottom: 20px;
padding: 16px;
background: var(--bg-color-secondary);
border-radius: 8px;
border-left: 3px solid #1677ff;
}
.guide-section:last-child {
margin-bottom: 0;
}
.guide-section-warning {
background: #fff7e6;
border-left-color: #faad14;
}
.guide-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--text-color);
margin-bottom: 12px;
}
body.dark .guide-section-warning {
background: rgba(250, 173, 20, 0.12);
border-left-color: #faad14;
}
.guide-section-icon {
font-size: 16px;
color: #1677ff;
}
.guide-section-warning .guide-section-icon {
color: #faad14;
}
.guide-section-content {
margin: 0;
font-size: 14px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.65);
}
.guide-list {
margin: 0;
padding-left: 20px;
list-style-type: disc;
}
.guide-list li {
font-size: 13px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.65);
margin-bottom: 8px;
}
.guide-list li:last-child {
margin-bottom: 0;
}
/* 步骤样式 */
.guide-steps {
margin-top: 12px;
}
.guide-steps .ant-steps-item-title {
font-size: 13px !important;
font-weight: 600 !important;
}
.guide-steps .ant-steps-item-description {
font-size: 13px !important;
line-height: 1.6 !important;
color: rgba(0, 0, 0, 0.65) !important;
}
/* 引导底部 */
.guide-footer {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 20px;
padding: 16px;
background: white;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.guide-footer-item {
display: flex;
align-items: center;
gap: 8px;
}
.guide-footer-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.guide-footer-kbd {
display: inline-block;
padding: 4px 10px;
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
border: 1px solid var(--border-color-strong);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
font-size: 11px;
font-family: 'Monaco', 'Consolas', monospace;
color: rgba(0, 0, 0, 0.88);
font-weight: 500;
}
/* 响应式调整 */
@media (max-width: 768px) {
.button-guide-modal {
max-width: calc(100% - 32px);
}
.button-guide-modal .ant-modal-body {
max-height: 500px;
}
.guide-footer {
flex-direction: column;
gap: 12px;
}
}

View File

@ -1,165 +0,0 @@
import { useState } from 'react'
import { Button, Modal, Steps, Tag } from 'antd'
import {
QuestionCircleOutlined,
BulbOutlined,
WarningOutlined,
CheckCircleOutlined,
InfoCircleOutlined,
} from '@ant-design/icons'
import './ButtonWithGuide.css'
/**
* 带引导的按钮组件 - 简洁现代设计
* 在按钮旁边显示一个简洁的帮助图标,点击后显示详细引导
*/
function ButtonWithGuide({
label,
icon,
type = 'default',
danger = false,
disabled = false,
onClick,
guide,
size = 'middle',
...restProps
}) {
const [showGuideModal, setShowGuideModal] = useState(false)
const handleGuideClick = (e) => {
e.stopPropagation()
if (guide) {
setShowGuideModal(true)
}
}
return (
<>
<div className="button-with-guide">
<Button
type={type}
icon={icon}
danger={danger}
disabled={disabled}
onClick={onClick}
size={size}
{...restProps}
>
{label}
</Button>
{guide && !disabled && (
<button className="guide-icon-btn" onClick={handleGuideClick} title="查看帮助">
<QuestionCircleOutlined />
</button>
)}
</div>
{/* 引导弹窗 */}
{guide && (
<Modal
title={
<div className="guide-modal-header">
<span className="guide-modal-icon">{guide.icon || icon}</span>
<span className="guide-modal-title">{guide.title}</span>
{guide.badge && (
<Tag color={guide.badge.color} className="guide-modal-badge">
{guide.badge.text}
</Tag>
)}
</div>
}
open={showGuideModal}
onCancel={() => setShowGuideModal(false)}
footer={[
<Button key="close" type="primary" onClick={() => setShowGuideModal(false)}>
知道了
</Button>,
]}
width={600}
className="button-guide-modal"
>
{/* 功能描述 */}
{guide.description && (
<div className="guide-section">
<div className="guide-section-title">
<InfoCircleOutlined className="guide-section-icon" />
功能说明
</div>
<p className="guide-section-content">{guide.description}</p>
</div>
)}
{/* 使用步骤 */}
{guide.steps && guide.steps.length > 0 && (
<div className="guide-section">
<div className="guide-section-title">
<CheckCircleOutlined className="guide-section-icon" />
操作步骤
</div>
<Steps
direction="vertical"
current={-1}
items={guide.steps.map((step, index) => ({
title: `步骤 ${index + 1}`,
description: step,
status: 'wait',
}))}
className="guide-steps"
/>
</div>
)}
{/* 使用场景 */}
{guide.scenarios && guide.scenarios.length > 0 && (
<div className="guide-section">
<div className="guide-section-title">
<BulbOutlined className="guide-section-icon" />
适用场景
</div>
<ul className="guide-list">
{guide.scenarios.map((scenario, index) => (
<li key={index}>{scenario}</li>
))}
</ul>
</div>
)}
{/* 注意事项 */}
{guide.warnings && guide.warnings.length > 0 && (
<div className="guide-section guide-section-warning">
<div className="guide-section-title">
<WarningOutlined className="guide-section-icon" />
注意事项
</div>
<ul className="guide-list">
{guide.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
{/* 快捷键和权限 */}
{(guide.shortcut || guide.permission) && (
<div className="guide-footer">
{guide.shortcut && (
<div className="guide-footer-item">
<span className="guide-footer-label">快捷键:</span>
<kbd className="guide-footer-kbd">{guide.shortcut}</kbd>
</div>
)}
{guide.permission && (
<div className="guide-footer-item">
<span className="guide-footer-label">权限要求:</span>
<Tag color="blue">{guide.permission}</Tag>
</div>
)}
</div>
)}
</Modal>
)}
</>
)
}
export default ButtonWithGuide

View File

@ -1,248 +0,0 @@
.button-guide-badge-wrapper {
display: inline-block;
position: relative;
}
/* 引导徽章样式 - 改为放在右上角外部 */
.button-guide-badge-wrapper .ant-badge {
display: block;
}
.button-guide-badge-wrapper .ant-badge-count {
top: -8px;
right: -8px;
transform: none;
}
/* 引导徽章样式 */
.guide-badge {
display: flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
background: #1677ff;
border-radius: 10px;
color: white;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
animation: pulseBadge 2s ease-in-out infinite;
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.4);
border: 2px solid white;
}
.guide-badge:hover {
animation: none;
transform: scale(1.2);
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.6);
}
.guide-badge-new {
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
box-shadow: 0 2px 8px rgba(82, 196, 26, 0.4);
}
.guide-badge-new:hover {
box-shadow: 0 4px 12px rgba(82, 196, 26, 0.6);
}
.guide-badge-help {
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.4);
}
.guide-badge-help:hover {
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.6);
}
.guide-badge-warn {
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
box-shadow: 0 2px 8px rgba(250, 173, 20, 0.4);
}
.guide-badge-warn:hover {
box-shadow: 0 4px 12px rgba(250, 173, 20, 0.6);
}
@keyframes pulseBadge {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.15);
opacity: 0.8;
}
}
/* 引导弹窗样式 */
.button-guide-modal .ant-modal-header {
padding: 20px 24px;
border-bottom: 2px solid var(--border-color);
}
.button-guide-modal .ant-modal-body {
padding: 24px;
max-height: 600px;
overflow-y: auto;
}
.guide-modal-header {
display: flex;
align-items: center;
gap: 12px;
}
.guide-modal-icon {
font-size: 24px;
color: #1677ff;
}
.guide-modal-title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.guide-modal-badge {
margin: 0;
font-size: 11px;
padding: 2px 8px;
}
/* 引导区块样式 */
.guide-section {
margin-bottom: 20px;
padding: 16px;
background: var(--bg-color-secondary);
border-radius: 8px;
border-left: 3px solid #1677ff;
}
.guide-section:last-child {
margin-bottom: 0;
}
.guide-section-warning {
background: #fff7e6;
border-left-color: #faad14;
}
.guide-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--text-color);
margin-bottom: 12px;
}
.guide-section-icon {
font-size: 16px;
color: #1677ff;
}
.guide-section-warning .guide-section-icon {
color: #faad14;
}
body.dark .guide-section-warning {
background: rgba(250, 173, 20, 0.12);
border-left-color: #faad14;
}
.guide-section-content {
margin: 0;
font-size: 14px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.65);
}
.guide-list {
margin: 0;
padding-left: 20px;
list-style-type: disc;
}
.guide-list li {
font-size: 13px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.65);
margin-bottom: 8px;
}
.guide-list li:last-child {
margin-bottom: 0;
}
/* 步骤样式 */
.guide-steps {
margin-top: 12px;
}
.guide-steps .ant-steps-item-title {
font-size: 13px !important;
font-weight: 600 !important;
}
.guide-steps .ant-steps-item-description {
font-size: 13px !important;
line-height: 1.6 !important;
color: rgba(0, 0, 0, 0.65) !important;
}
/* 引导底部 */
.guide-footer {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 20px;
padding: 16px;
background: white;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.guide-footer-item {
display: flex;
align-items: center;
gap: 8px;
}
.guide-footer-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.guide-footer-kbd {
display: inline-block;
padding: 4px 10px;
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
border: 1px solid var(--border-color-strong);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
font-size: 11px;
font-family: 'Monaco', 'Consolas', monospace;
color: rgba(0, 0, 0, 0.88);
font-weight: 500;
}
/* 响应式调整 */
@media (max-width: 768px) {
.button-guide-modal {
max-width: calc(100% - 32px);
}
.button-guide-modal .ant-modal-body {
max-height: 500px;
}
.guide-footer {
flex-direction: column;
gap: 12px;
}
}

View File

@ -1,222 +0,0 @@
import { useState } from 'react'
import { Button, Badge, Modal, Steps, Tag, Divider } from 'antd'
import {
QuestionCircleOutlined,
BulbOutlined,
WarningOutlined,
CheckCircleOutlined,
InfoCircleOutlined,
} from '@ant-design/icons'
import './ButtonWithGuideBadge.css'
/**
* 智能引导徽章按钮组件
* 为新功能或复杂按钮添加脉冲动画的徽章,点击后显示详细引导
* @param {Object} props
* @param {string} props.label - 按钮文本
* @param {ReactNode} props.icon - 按钮图标
* @param {string} props.type - 按钮类型
* @param {boolean} props.danger - 危险按钮
* @param {boolean} props.disabled - 禁用状态
* @param {Function} props.onClick - 点击回调
* @param {Object} props.guide - 引导配置
* @param {boolean} props.showBadge - 是否显示徽章
* @param {string} props.badgeType - 徽章类型:new, help, warn
* @param {string} props.size - 按钮大小
*/
function ButtonWithGuideBadge({
label,
icon,
type = 'default',
danger = false,
disabled = false,
onClick,
guide,
showBadge = true,
badgeType = 'help',
size = 'middle',
...restProps
}) {
const [showGuideModal, setShowGuideModal] = useState(false)
const handleBadgeClick = (e) => {
e.stopPropagation()
if (guide) {
setShowGuideModal(true)
}
}
const getBadgeConfig = () => {
const configs = {
new: {
text: 'NEW',
color: '#52c41a',
icon: <InfoCircleOutlined />,
},
help: {
text: '?',
color: '#1677ff',
icon: <QuestionCircleOutlined />,
},
warn: {
text: '!',
color: '#faad14',
icon: <WarningOutlined />,
},
}
return configs[badgeType] || configs.help
}
const badgeConfig = getBadgeConfig()
return (
<>
<div className="button-guide-badge-wrapper">
{showBadge && guide && !disabled ? (
<Badge
count={
<div
className={`guide-badge guide-badge-${badgeType}`}
onClick={handleBadgeClick}
>
{badgeConfig.icon}
</div>
}
offset={[-5, 5]}
>
<Button
type={type}
icon={icon}
danger={danger}
disabled={disabled}
onClick={onClick}
size={size}
{...restProps}
>
{label}
</Button>
</Badge>
) : (
<Button
type={type}
icon={icon}
danger={danger}
disabled={disabled}
onClick={onClick}
size={size}
{...restProps}
>
{label}
</Button>
)}
</div>
{/* 引导弹窗 */}
{guide && (
<Modal
title={
<div className="guide-modal-header">
<span className="guide-modal-icon">{guide.icon || icon}</span>
<span className="guide-modal-title">{guide.title}</span>
{guide.badge && (
<Tag color={guide.badge.color} className="guide-modal-badge">
{guide.badge.text}
</Tag>
)}
</div>
}
open={showGuideModal}
onCancel={() => setShowGuideModal(false)}
footer={[
<Button key="close" type="primary" onClick={() => setShowGuideModal(false)}>
知道了
</Button>,
]}
width={600}
className="button-guide-modal"
>
{/* 功能描述 */}
{guide.description && (
<div className="guide-section">
<div className="guide-section-title">
<InfoCircleOutlined className="guide-section-icon" />
功能说明
</div>
<p className="guide-section-content">{guide.description}</p>
</div>
)}
{/* 使用步骤 */}
{guide.steps && guide.steps.length > 0 && (
<div className="guide-section">
<div className="guide-section-title">
<CheckCircleOutlined className="guide-section-icon" />
操作步骤
</div>
<Steps
direction="vertical"
current={-1}
items={guide.steps.map((step, index) => ({
title: `步骤 ${index + 1}`,
description: step,
status: 'wait',
}))}
className="guide-steps"
/>
</div>
)}
{/* 使用场景 */}
{guide.scenarios && guide.scenarios.length > 0 && (
<div className="guide-section">
<div className="guide-section-title">
<BulbOutlined className="guide-section-icon" />
适用场景
</div>
<ul className="guide-list">
{guide.scenarios.map((scenario, index) => (
<li key={index}>{scenario}</li>
))}
</ul>
</div>
)}
{/* 注意事项 */}
{guide.warnings && guide.warnings.length > 0 && (
<div className="guide-section guide-section-warning">
<div className="guide-section-title">
<WarningOutlined className="guide-section-icon" />
注意事项
</div>
<ul className="guide-list">
{guide.warnings.map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
{/* 快捷键和权限 */}
{(guide.shortcut || guide.permission) && (
<div className="guide-footer">
{guide.shortcut && (
<div className="guide-footer-item">
<span className="guide-footer-label">快捷键:</span>
<kbd className="guide-footer-kbd">{guide.shortcut}</kbd>
</div>
)}
{guide.permission && (
<div className="guide-footer-item">
<span className="guide-footer-label">权限要求:</span>
<Tag color="blue">{guide.permission}</Tag>
</div>
)}
</div>
)}
</Modal>
)}
</>
)
}
export default ButtonWithGuideBadge

View File

@ -1,194 +0,0 @@
.button-hover-card-wrapper {
display: inline-block;
position: relative;
}
/* 悬浮卡片 */
.hover-info-card {
position: fixed;
z-index: 10000;
transform: translateY(-50%);
opacity: 0;
animation: slideInRight 0.3s ease forwards;
pointer-events: none;
}
.hover-info-card-visible {
opacity: 1;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateY(-50%) translateX(-20px);
}
to {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
}
.hover-info-card-content {
width: 340px;
background: white;
border-radius: 12px;
box-shadow:
0 12px 28px rgba(0, 0, 0, 0.12),
0 6px 12px rgba(0, 0, 0, 0.08),
0 0 2px rgba(0, 0, 0, 0.04);
overflow: hidden;
}
.hover-info-card-content .ant-card-body {
padding: 16px;
}
/* 卡片头部 */
.hover-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.hover-card-title-wrapper {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.hover-card-icon {
font-size: 20px;
color: #1677ff;
}
.hover-card-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.hover-card-badge {
margin: 0;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
}
/* 卡片描述 */
.hover-card-description {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--text-color-secondary);
}
/* 卡片区块 */
.hover-card-section {
margin-top: 12px;
padding: 10px;
background: var(--bg-color-secondary);
border-radius: 8px;
border-left: 3px solid #1677ff;
}
.hover-card-warning {
background: #fff7e6;
border-left-color: #faad14;
}
.hover-card-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-color);
margin-bottom: 8px;
}
body.dark .hover-card-warning {
background: rgba(250, 173, 20, 0.12);
border-left-color: #faad14;
}
.section-icon {
font-size: 12px;
color: #1677ff;
}
.hover-card-warning .section-icon {
color: #faad14;
}
.hover-card-list {
margin: 0;
padding-left: 16px;
list-style-type: disc;
}
.hover-card-list li {
font-size: 12px;
line-height: 1.6;
color: rgba(0, 0, 0, 0.65);
margin-bottom: 4px;
}
.hover-card-list li:last-child {
margin-bottom: 0;
}
/* 卡片底部 */
.hover-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.footer-label {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.footer-kbd {
display: inline-block;
padding: 4px 10px;
background: linear-gradient(180deg, #ffffff 0%, #f0f0f0 100%);
border: 1px solid var(--border-color-strong);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), inset 0 -2px 0 rgba(0, 0, 0, 0.05);
font-size: 11px;
font-family: 'Monaco', 'Consolas', monospace;
color: rgba(0, 0, 0, 0.88);
font-weight: 500;
}
/* 响应式调整 */
@media (max-width: 768px) {
.hover-info-card-content {
width: 280px;
}
.hover-info-card {
left: 50% !important;
transform: translateX(-50%) translateY(-50%);
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(-50%) translateY(-50%) scale(0.95);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(-50%) scale(1);
}
}
}

View File

@ -1,179 +0,0 @@
import { useState, useRef } from 'react'
import { createPortal } from 'react-dom'
import { Button, Card, Tag } from 'antd'
import {
BulbOutlined,
WarningOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'
import './ButtonWithHoverCard.css'
/**
* 悬浮展开卡片按钮组件
* 鼠标悬停时,在按钮旁边展开一个精美的信息卡片
* @param {Object} props
* @param {string} props.label - 按钮文本
* @param {ReactNode} props.icon - 按钮图标
* @param {string} props.type - 按钮类型
* @param {boolean} props.danger - 危险按钮
* @param {boolean} props.disabled - 禁用状态
* @param {Function} props.onClick - 点击回调
* @param {Object} props.cardInfo - 卡片信息配置
* @param {string} props.size - 按钮大小
*/
function ButtonWithHoverCard({
label,
icon,
type = 'default',
danger = false,
disabled = false,
onClick,
cardInfo,
size = 'middle',
...restProps
}) {
const [showCard, setShowCard] = useState(false)
const [cardPosition, setCardPosition] = useState({ top: 0, left: 0 })
const wrapperRef = useRef(null)
const handleMouseEnter = () => {
if (!cardInfo || disabled) return
if (wrapperRef.current) {
const rect = wrapperRef.current.getBoundingClientRect()
setCardPosition({
top: rect.top + rect.height / 2,
left: rect.right + 12,
})
}
setShowCard(true)
}
const handleMouseLeave = () => {
setShowCard(false)
}
// 渲染悬浮卡片
const renderCard = () => {
if (!showCard || !cardInfo) return null
return (
<div
className={`hover-info-card ${showCard ? 'hover-info-card-visible' : ''}`}
style={{
top: cardPosition.top,
left: cardPosition.left,
}}
>
<Card
size="small"
bordered={false}
className="hover-info-card-content"
>
{/* 标题区 */}
<div className="hover-card-header">
<div className="hover-card-title-wrapper">
{cardInfo.icon && (
<span className="hover-card-icon">{cardInfo.icon}</span>
)}
<h4 className="hover-card-title">{cardInfo.title}</h4>
</div>
{cardInfo.badge && (
<Tag color={cardInfo.badge.color} className="hover-card-badge">
{cardInfo.badge.text}
</Tag>
)}
</div>
{/* 描述 */}
{cardInfo.description && (
<div className="hover-card-section">
<p className="hover-card-description">{cardInfo.description}</p>
</div>
)}
{/* 使用场景 */}
{cardInfo.scenarios && cardInfo.scenarios.length > 0 && (
<div className="hover-card-section">
<div className="hover-card-section-title">
<BulbOutlined className="section-icon" />
使用场景
</div>
<ul className="hover-card-list">
{cardInfo.scenarios.slice(0, 2).map((scenario, index) => (
<li key={index}>{scenario}</li>
))}
</ul>
</div>
)}
{/* 快速提示 */}
{cardInfo.quickTips && cardInfo.quickTips.length > 0 && (
<div className="hover-card-section">
<div className="hover-card-section-title">
<ThunderboltOutlined className="section-icon" />
快速提示
</div>
<ul className="hover-card-list">
{cardInfo.quickTips.map((tip, index) => (
<li key={index}>{tip}</li>
))}
</ul>
</div>
)}
{/* 注意事项 */}
{cardInfo.warnings && cardInfo.warnings.length > 0 && (
<div className="hover-card-section hover-card-warning">
<div className="hover-card-section-title">
<WarningOutlined className="section-icon" />
注意
</div>
<ul className="hover-card-list">
{cardInfo.warnings.slice(0, 2).map((warning, index) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
{/* 快捷键 */}
{cardInfo.shortcut && (
<div className="hover-card-footer">
<span className="footer-label">快捷键</span>
<kbd className="footer-kbd">{cardInfo.shortcut}</kbd>
</div>
)}
</Card>
</div>
)
}
return (
<>
<div
ref={wrapperRef}
className="button-hover-card-wrapper"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Button
type={type}
icon={icon}
danger={danger}
disabled={disabled}
onClick={onClick}
size={size}
{...restProps}
>
{label}
</Button>
</div>
{/* 使用 Portal 渲染悬浮卡片到 body */}
{typeof document !== 'undefined' && createPortal(renderCard(), document.body)}
</>
)
}
export default ButtonWithHoverCard

View File

@ -1,163 +0,0 @@
/* 按钮包裹容器 */
.button-with-tip-wrapper {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
}
.button-with-tip {
transition: all 0.3s ease;
}
/* 提示指示器 */
.button-tip-indicator {
font-size: 12px;
color: rgba(0, 0, 0, 0.25);
cursor: help;
transition: all 0.3s ease;
animation: pulse 2s ease-in-out infinite;
}
.button-with-tip-wrapper:hover .button-tip-indicator {
color: #1677ff;
animation: none;
}
/* 脉冲动画 */
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.1);
}
}
/* 提示框样式 */
.button-tip-overlay {
max-width: 360px;
}
.button-tip-overlay .ant-tooltip-inner {
padding: 12px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3);
}
.button-tip-overlay .ant-tooltip-arrow {
--antd-arrow-background-color: #667eea;
}
.button-tip-overlay .ant-tooltip-arrow-content {
background: #667eea;
}
/* 提示内容布局 */
.button-tip-content {
display: flex;
flex-direction: column;
gap: 8px;
color: #ffffff;
font-size: 13px;
line-height: 1.6;
}
.button-tip-title {
font-size: 14px;
font-weight: 600;
color: #ffffff;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
padding-bottom: 6px;
}
.button-tip-description {
color: rgba(255, 255, 255, 0.95);
font-size: 13px;
}
.button-tip-shortcut {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.15);
}
.tip-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
}
.tip-kbd {
display: inline-block;
padding: 2px 8px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
font-size: 11px;
font-family: 'Monaco', 'Consolas', monospace;
color: #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.button-tip-notes {
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.15);
}
.tip-notes-title {
font-size: 12px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 6px;
}
.tip-notes-list {
margin: 0;
padding-left: 16px;
list-style-type: disc;
}
.tip-notes-list li {
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
margin-bottom: 4px;
}
.tip-notes-list li:last-child {
margin-bottom: 0;
}
/* 不同主题的提示框 */
.tip-theme-success.button-tip-overlay .ant-tooltip-inner {
background: linear-gradient(135deg, #56ab2f 0%, #a8e063 100%);
}
.tip-theme-warning.button-tip-overlay .ant-tooltip-inner {
background: linear-gradient(135deg, #f7971e 0%, #ffd200 100%);
}
.tip-theme-danger.button-tip-overlay .ant-tooltip-inner {
background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);
}
.tip-theme-info.button-tip-overlay .ant-tooltip-inner {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
/* 响应式调整 */
@media (max-width: 768px) {
.button-tip-overlay {
max-width: 280px;
}
.button-tip-indicator {
display: none;
}
}

View File

@ -1,105 +0,0 @@
import { Button, Tooltip } from 'antd'
import { QuestionCircleOutlined } from '@ant-design/icons'
import './ButtonWithTip.css'
/**
* 带有增强提示的按钮组件
* @param {Object} props
* @param {string} props.label - 按钮文本
* @param {ReactNode} props.icon - 按钮图标
* @param {string} props.type - 按钮类型
* @param {boolean} props.danger - 危险按钮
* @param {boolean} props.disabled - 禁用状态
* @param {Function} props.onClick - 点击回调
* @param {Object} props.tip - 提示配置
* @param {string} props.tip.title - 提示标题
* @param {string} props.tip.description - 详细描述
* @param {string} props.tip.shortcut - 快捷键提示
* @param {Array} props.tip.notes - 注意事项列表
* @param {string} props.tip.placement - 提示位置
* @param {boolean} props.showTipIcon - 是否显示提示图标
* @param {string} props.size - 按钮大小
*/
function ButtonWithTip({
label,
icon,
type = 'default',
danger = false,
disabled = false,
onClick,
tip,
showTipIcon = true,
size = 'middle',
...restProps
}) {
// 如果没有提示配置,直接返回普通按钮
if (!tip) {
return (
<Button
type={type}
icon={icon}
danger={danger}
disabled={disabled}
onClick={onClick}
size={size}
{...restProps}
>
{label}
</Button>
)
}
// 构建提示内容
const tooltipContent = (
<div className="button-tip-content">
{tip.title && <div className="button-tip-title">{tip.title}</div>}
{tip.description && <div className="button-tip-description">{tip.description}</div>}
{tip.shortcut && (
<div className="button-tip-shortcut">
<span className="tip-label">快捷键:</span>
<kbd className="tip-kbd">{tip.shortcut}</kbd>
</div>
)}
{tip.notes && tip.notes.length > 0 && (
<div className="button-tip-notes">
<div className="tip-notes-title">注意事项:</div>
<ul className="tip-notes-list">
{tip.notes.map((note, index) => (
<li key={index}>{note}</li>
))}
</ul>
</div>
)}
</div>
)
return (
<Tooltip
title={tooltipContent}
placement={tip.placement || 'top'}
classNames={{ root: 'button-tip-overlay' }}
mouseEnterDelay={0.3}
arrow={{ pointAtCenter: true }}
>
<div className="button-with-tip-wrapper">
<Button
type={type}
icon={icon}
danger={danger}
disabled={disabled}
onClick={onClick}
size={size}
className="button-with-tip"
{...restProps}
>
{label}
</Button>
{showTipIcon && !disabled && (
<QuestionCircleOutlined className="button-tip-indicator" />
)}
</div>
</Tooltip>
)
}
export default ButtonWithTip

View File

@ -1,17 +0,0 @@
/* 图表面板 */
.chart-panel {
margin-bottom: 16px;
}
.chart-panel:last-child {
margin-bottom: 0;
}
.chart-panel-title {
font-size: 13px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 12px;
padding-left: 8px;
border-left: 3px solid #1677ff;
}

View File

@ -1,202 +0,0 @@
import { useEffect, useRef } from 'react'
import * as echarts from 'echarts'
import './ChartPanel.css'
/**
* 图表面板组件
* @param {Object} props
* @param {string} props.type - 图表类型: 'line' | 'bar' | 'pie' | 'ring'
* @param {string} props.title - 图表标题
* @param {Object} props.data - 图表数据
* @param {number} props.height - 图表高度,默认 200px
* @param {Object} props.option - 自定义 ECharts 配置
* @param {string} props.className - 自定义类名
*/
function ChartPanel({ type = 'line', title, data, height = 200, option = {}, className = '' }) {
const chartRef = useRef(null)
const chartInstance = useRef(null)
useEffect(() => {
if (!chartRef.current || !data) return
// 使用 setTimeout 确保 DOM 完全渲染
const timer = setTimeout(() => {
// 初始化图表
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current)
}
// 根据类型生成配置
const chartOption = getChartOption(type, data, option)
chartInstance.current.setOption(chartOption, true)
}, 0)
// 窗口大小改变时重绘(使用 passive 选项)
const handleResize = () => {
if (chartInstance.current) {
chartInstance.current.resize()
}
}
// 添加被动事件监听器
window.addEventListener('resize', handleResize, { passive: true })
return () => {
clearTimeout(timer)
window.removeEventListener('resize', handleResize)
}
}, [type, data, option])
// 组件卸载时销毁图表
useEffect(() => {
return () => {
chartInstance.current?.dispose()
}
}, [])
return (
<div className={`chart-panel ${className}`}>
{title && <div className="chart-panel-title">{title}</div>}
<div ref={chartRef} style={{ width: '100%', height: `${height}px` }} />
</div>
)
}
/**
* 根据图表类型生成 ECharts 配置
*/
function getChartOption(type, data, customOption) {
const baseOption = {
grid: {
left: '10%',
right: '5%',
top: '15%',
bottom: '15%',
},
tooltip: {
trigger: type === 'pie' || type === 'ring' ? 'item' : 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#e8e8e8',
borderWidth: 1,
textStyle: {
color: '#333',
},
},
}
switch (type) {
case 'line':
return {
...baseOption,
xAxis: {
type: 'category',
data: data.xAxis || [],
boundaryGap: false,
axisLine: { lineStyle: { color: '#e8e8e8' } },
axisLabel: { color: '#8c8c8c', fontSize: 11 },
},
yAxis: {
type: 'value',
axisLine: { lineStyle: { color: '#e8e8e8' } },
axisLabel: { color: '#8c8c8c', fontSize: 11 },
splitLine: { lineStyle: { color: '#f0f0f0' } },
},
series: [
{
type: 'line',
data: data.series || [],
smooth: true,
lineStyle: { width: 2, color: '#1677ff' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(22, 119, 255, 0.3)' },
{ offset: 1, color: 'rgba(22, 119, 255, 0.05)' },
]),
},
symbol: 'circle',
symbolSize: 6,
itemStyle: { color: '#1677ff' },
},
],
...customOption,
}
case 'bar':
return {
...baseOption,
xAxis: {
type: 'category',
data: data.xAxis || [],
axisLine: { lineStyle: { color: '#e8e8e8' } },
axisLabel: { color: '#8c8c8c', fontSize: 11 },
},
yAxis: {
type: 'value',
axisLine: { lineStyle: { color: '#e8e8e8' } },
axisLabel: { color: '#8c8c8c', fontSize: 11 },
splitLine: { lineStyle: { color: '#f0f0f0' } },
},
series: [
{
type: 'bar',
data: data.series || [],
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#4096ff' },
{ offset: 1, color: '#1677ff' },
]),
borderRadius: [4, 4, 0, 0],
},
barWidth: '50%',
},
],
...customOption,
}
case 'pie':
case 'ring':
return {
...baseOption,
grid: undefined,
legend: {
orient: 'vertical',
right: '10%',
top: 'center',
textStyle: { color: '#8c8c8c', fontSize: 12 },
},
series: [
{
type: 'pie',
radius: type === 'ring' ? ['40%', '65%'] : '65%',
center: ['40%', '50%'],
data: data.series || [],
label: {
fontSize: 11,
color: '#8c8c8c',
},
labelLine: {
lineStyle: { color: '#d9d9d9' },
},
itemStyle: {
borderRadius: 4,
borderColor: '#fff',
borderWidth: 2,
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.3)',
},
},
},
],
...customOption,
}
default:
return { ...baseOption, ...customOption }
}
}
export default ChartPanel

View File

@ -1,138 +0,0 @@
import { Modal } from 'antd'
import { ExclamationCircleOutlined, DeleteOutlined } from '@ant-design/icons'
/**
* 标准确认对话框组件
* @param {Object} options - 对话框配置
* @param {string} options.title - 标题
* @param {string|ReactNode} options.content - 内容
* @param {string} options.okText - 确认按钮文字
* @param {string} options.cancelText - 取消按钮文字
* @param {string} options.type - 类型: 'warning', 'danger', 'info'
* @param {Function} options.onOk - 确认回调
* @param {Function} options.onCancel - 取消回调
*/
const ConfirmDialog = {
/**
* 显示删除确认对话框(单个项目)
*/
delete: ({ title = '确认删除', itemName, itemInfo, onOk, onCancel }) => {
Modal.confirm({
title,
content: (
<div>
<p>您确定要删除以下项目吗?</p>
<div style={{ marginTop: 12, padding: 12, background: 'var(--bg-color-secondary)', borderRadius: 6 }}>
<p style={{ margin: 0, fontWeight: 500 }}>{itemName}</p>
{itemInfo && (
<p style={{ margin: '4px 0 0 0', fontSize: 13, color: 'var(--text-color-secondary)' }}>{itemInfo}</p>
)}
</div>
<p style={{ marginTop: 12, color: '#ff4d4f', fontSize: 13 }}>
此操作不可恢复,请谨慎操作!
</p>
</div>
),
okText: '确认删除',
cancelText: '取消',
okType: 'danger',
centered: true,
icon: <DeleteOutlined style={{ color: '#ff4d4f' }} />,
onOk,
onCancel,
})
},
/**
* 显示批量删除确认对话框
*/
batchDelete: ({ count, items, onOk, onCancel }) => {
Modal.confirm({
title: '批量删除确认',
content: (
<div>
<p>您确定要删除选中的 {count} 个项目吗?</p>
<div
style={{
marginTop: 12,
padding: 12,
background: 'var(--bg-color-secondary)',
borderRadius: 6,
maxHeight: 200,
overflowY: 'auto',
}}
>
{items.map((item, index) => (
<div
key={index}
style={{
padding: '6px 0',
borderBottom: index < items.length - 1 ? '1px solid var(--border-color)' : 'none',
}}
>
<span style={{ fontWeight: 500 }}>{item.name}</span>
{item.info && (
<span style={{ marginLeft: 12, fontSize: 13, color: 'var(--text-color-secondary)' }}>
({item.info})
</span>
)}
</div>
))}
</div>
<p style={{ marginTop: 12, color: '#ff4d4f', fontSize: 13 }}>
此操作不可恢复,请谨慎操作!
</p>
</div>
),
okText: '确认删除',
cancelText: '取消',
okType: 'danger',
centered: true,
icon: <DeleteOutlined style={{ color: '#ff4d4f' }} />,
onOk,
onCancel,
})
},
/**
* 显示警告确认对话框
*/
warning: ({ title, content, okText = '确定', cancelText = '取消', onOk, onCancel }) => {
Modal.confirm({
title,
content,
okText,
cancelText,
centered: true,
icon: <ExclamationCircleOutlined style={{ color: '#faad14' }} />,
onOk,
onCancel,
})
},
/**
* 显示通用确认对话框
*/
confirm: ({
title,
content,
okText = '确定',
cancelText = '取消',
okType = 'primary',
onOk,
onCancel,
}) => {
Modal.confirm({
title,
content,
okText,
cancelText,
okType,
centered: true,
onOk,
onCancel,
})
},
}
export default ConfirmDialog

View File

@ -1,119 +0,0 @@
/* 详情抽屉容器 */
.detail-drawer-content {
height: 100%;
display: flex;
flex-direction: column;
}
/* 顶部信息区域 - 固定不滚动 */
.detail-drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: var(--bg-color-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.detail-drawer-header-left {
display: flex;
align-items: center;
gap: 16px;
}
.detail-drawer-close-button {
font-size: 18px;
color: var(--text-color-secondary);
}
.detail-drawer-close-button:hover {
color: #1677ff;
}
.detail-drawer-header-info {
display: flex;
align-items: center;
gap: 12px;
}
.detail-drawer-title-icon {
font-size: 18px;
color: #1677ff;
}
.detail-drawer-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.detail-drawer-badge {
display: flex;
align-items: center;
}
.detail-drawer-header-right {
flex: 1;
display: flex;
justify-content: flex-end;
}
/* 可滚动内容区域 */
.detail-drawer-scrollable-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 24px;
}
/* 标签页区域 */
.detail-drawer-tabs {
background: var(--card-bg);
padding: 0;
min-height: 400px;
}
.detail-drawer-tabs :global(.ant-tabs) {
height: 100%;
}
.detail-drawer-tabs :global(.ant-tabs-content-holder) {
overflow: visible;
}
.detail-drawer-tabs :global(.ant-tabs-nav) {
padding: 0;
margin: 0 0 16px 0;
background: transparent;
}
.detail-drawer-tabs :global(.ant-tabs-nav::before) {
border-bottom: 1px solid var(--border-color);
}
.detail-drawer-tabs :global(.ant-tabs-tab) {
padding: 12px 0;
margin: 0 32px 0 0;
font-size: 14px;
font-weight: 500;
}
.detail-drawer-tabs :global(.ant-tabs-tab:first-child) {
margin-left: 0;
}
.detail-drawer-tabs :global(.ant-tabs-tab-active .ant-tabs-tab-btn) {
color: #d946ef;
}
.detail-drawer-tabs :global(.ant-tabs-ink-bar) {
background: #d946ef;
height: 3px;
}
.detail-drawer-tab-content {
padding: 0;
background: var(--card-bg);
}

View File

@ -1,97 +0,0 @@
import { Drawer, Button, Space, Tabs } from 'antd'
import { CloseOutlined } from '@ant-design/icons'
import './DetailDrawer.css'
/**
* 详情抽屉组件
* @param {Object} props
* @param {boolean} props.visible - 是否显示抽屉
* @param {Function} props.onClose - 关闭回调
* @param {Object} props.title - 标题配置
* @param {string} props.title.text - 标题文本
* @param {ReactNode} props.title.badge - 状态徽标(可选)
* @param {ReactNode} props.title.icon - 图标(可选)
* @param {Array} props.headerActions - 顶部操作按钮
* @param {number} props.width - 抽屉宽度
* @param {ReactNode} props.children - 主要内容
* @param {Array} props.tabs - 标签页配置(可选)
*/
function DetailDrawer({
visible,
onClose,
title,
headerActions = [],
width = 1080,
children,
tabs,
}) {
return (
<Drawer
title={null}
placement="right"
width={width}
onClose={onClose}
open={visible}
closable={false}
styles={{ body: { padding: 0 } }}
>
<div className="detail-drawer-content">
{/* 顶部标题栏 - 固定不滚动 */}
<div className="detail-drawer-header">
<div className="detail-drawer-header-left">
<Button
type="text"
icon={<CloseOutlined />}
onClick={onClose}
className="detail-drawer-close-button"
/>
<div className="detail-drawer-header-info">
{title?.icon && <span className="detail-drawer-title-icon">{title.icon}</span>}
<h2 className="detail-drawer-title">{title?.text}</h2>
{title?.badge && <span className="detail-drawer-badge">{title.badge}</span>}
</div>
</div>
<div className="detail-drawer-header-right">
<Space size="middle">
{headerActions.map((action) => (
<Button
key={action.key}
type={action.type || 'default'}
icon={action.icon}
danger={action.danger}
disabled={action.disabled}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</Space>
</div>
</div>
{/* 可滚动内容区域 */}
<div className="detail-drawer-scrollable-content">
{children}
{/* 可选的标签页区域 */}
{tabs && tabs.length > 0 && (
<div className="detail-drawer-tabs">
<Tabs
defaultActiveKey={tabs[0].key}
type="line"
size="large"
items={tabs.map((tab) => ({
key: tab.key,
label: tab.label,
children: <div className="detail-drawer-tab-content">{tab.content}</div>,
}))}
/>
</div>
)}
</div>
</div>
</Drawer>
)
}
export default DetailDrawer

View File

@ -1,37 +0,0 @@
import { FloatButton, Tooltip } from 'antd'
import { MenuOutlined, FilePdfOutlined, VerticalAlignTopOutlined } from '@ant-design/icons'
/**
* 文档浮动操作按钮组(导出 PDF + 回到顶部)
* @param {object} props
* @param {function} props.onExportPDF - 导出 PDF 回调
* @param {React.RefObject} props.scrollRef - 滚动容器 ref
* @param {number} [props.right=24] - 距右侧距离
*/
export default function DocFloatActions({ onExportPDF, scrollRef, right = 24 }) {
return (
<FloatButton.Group
trigger="hover"
type="primary"
icon={<MenuOutlined />}
style={{ right }}
>
<Tooltip title="导出 PDF" placement="left">
<FloatButton
icon={<FilePdfOutlined />}
onClick={onExportPDF}
/>
</Tooltip>
<Tooltip title="回到顶部" placement="left">
<FloatButton
icon={<VerticalAlignTopOutlined />}
onClick={() => {
if (scrollRef?.current) {
scrollRef.current.scrollTo({ top: 0, behavior: 'smooth' })
}
}}
/>
</Tooltip>
</FloatButton.Group>
)
}

View File

@ -1,105 +0,0 @@
/* 扩展信息面板容器 */
.extend-info-panel {
display: flex;
gap: 16px;
width: 100%;
}
/* 垂直布局(默认) */
.extend-info-panel-vertical {
flex-direction: column;
}
/* 水平布局 */
.extend-info-panel-horizontal {
flex-direction: row;
flex-wrap: wrap;
}
/* 信息区块 */
.extend-info-section {
background: var(--card-bg);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
overflow: hidden;
transition: all 0.3s ease;
}
/* 水平布局时区块自适应宽度 */
.extend-info-panel-horizontal .extend-info-section {
flex: 1;
min-width: 0;
}
.extend-info-section:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 区块头部 */
.extend-info-section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
border-bottom: 1px solid var(--border-color);
cursor: pointer;
user-select: none;
transition: background 0.2s ease;
}
.extend-info-section-header:hover {
background: linear-gradient(135deg, #f0f4ff 0%, #e8f0ff 100%);
}
.extend-info-section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.extend-info-section-icon {
display: flex;
align-items: center;
font-size: 16px;
color: #1677ff;
}
.extend-info-section-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: #8c8c8c;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 4px;
}
.extend-info-section-toggle:hover {
background: rgba(0, 0, 0, 0.06);
color: #1677ff;
}
/* 区块内容 */
.extend-info-section-content {
padding: 16px 20px;
animation: expandContent 0.3s ease-out;
}
@keyframes expandContent {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -1,68 +0,0 @@
import { useState } from 'react'
import { UpOutlined, DownOutlined } from '@ant-design/icons'
import './ExtendInfoPanel.css'
/**
* 扩展信息面板组件
* @param {Object} props
* @param {Array} props.sections - 信息区块配置数组
* @param {string} props.sections[].key - 区块唯一键
* @param {string} props.sections[].title - 区块标题
* @param {ReactNode} props.sections[].icon - 标题图标
* @param {ReactNode} props.sections[].content - 区块内容
* @param {boolean} props.sections[].defaultCollapsed - 默认是否折叠
* @param {boolean} props.sections[].hideTitleBar - 是否隐藏该区块的标题栏(默认 false)
* @param {string} props.layout - 布局方式:'vertical'(垂直堆叠)| 'horizontal'(水平排列)
* @param {string} props.className - 自定义类名
*/
function ExtendInfoPanel({ sections = [], layout = 'vertical', className = '' }) {
const [collapsedSections, setCollapsedSections] = useState(() => {
const initial = {}
sections.forEach((section) => {
if (section.defaultCollapsed) {
initial[section.key] = true
}
})
return initial
})
const toggleSection = (key) => {
setCollapsedSections((prev) => ({
...prev,
[key]: !prev[key],
}))
}
return (
<div className={`extend-info-panel extend-info-panel-${layout} ${className}`}>
{sections.map((section) => {
const isCollapsed = collapsedSections[section.key]
const hideTitleBar = section.hideTitleBar === true
return (
<div key={section.key} className="extend-info-section">
{/* 区块头部 - 可配置隐藏 */}
{!hideTitleBar && (
<div className="extend-info-section-header" onClick={() => toggleSection(section.key)}>
<div className="extend-info-section-title">
{section.icon && <span className="extend-info-section-icon">{section.icon}</span>}
<span>{section.title}</span>
</div>
<button className="extend-info-section-toggle" type="button">
{isCollapsed ? <DownOutlined /> : <UpOutlined />}
</button>
</div>
)}
{/* 区块内容 - 如果隐藏标题栏则总是显示,否则根据折叠状态 */}
{(hideTitleBar || !isCollapsed) && (
<div className="extend-info-section-content">{section.content}</div>
)}
</div>
)
})}
</div>
)
}
export default ExtendInfoPanel

View File

@ -0,0 +1,18 @@
import { useEffect } from 'react'
import { App } from 'antd'
import { setFeedbackApi } from './feedbackApi'
/**
* 把 AntD App 上下文里的 message / notification / modal 注册到全局反馈通道。
* 必须渲染在 <ConfigProvider> + <App> 内部。
*/
export default function FeedbackBridge({ children }) {
const api = App.useApp()
useEffect(() => {
setFeedbackApi(api)
return () => setFeedbackApi(null)
}, [api])
return children
}

View File

@ -0,0 +1,29 @@
import { Modal, message as staticMessage, notification as staticNotification } from 'antd'
/**
* 全局反馈通道(message / notification / modal)
*
* AntD 5 的静态方法(message.success、Modal.confirm 等)不会继承 ConfigProvider 的主题,
* 暗色模式下会弹出浅色气泡,交互风格也不统一。因此统一通过 FeedbackBridge 注入
* App.useApp() 返回的上下文实例,业务代码继续调用 Toast.xxx 即可。
*
* 静态实例仅作为 React 树之外(如 axios 拦截器)的兜底。
*/
const fallbackApi = {
message: staticMessage,
// 静态 Modal.confirm 仅作为 React 树之外的兜底
modal: { confirm: (config) => Modal.confirm(config) },
notification: staticNotification,
}
let currentApi = fallbackApi
export function setFeedbackApi(api) {
currentApi = api ? { ...fallbackApi, ...api } : fallbackApi
}
export function getFeedbackApi() {
return currentApi
}
export default getFeedbackApi

View File

@ -1,94 +0,0 @@
/* 信息面板 */
.info-panel {
padding: 0;
background: var(--card-bg);
}
/* 信息区域容器 */
.info-panel > :global(.ant-row) {
padding: 24px;
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
}
.info-panel-item {
display: flex;
flex-direction: column;
gap: 5px;
padding: 10px 0;
border-bottom: 1px solid var(--border-color);
transition: all 0.2s ease;
position: relative;
}
.info-panel-item:last-child {
border-bottom: none;
}
/* 添加左侧装饰条 */
.info-panel-item::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
background: linear-gradient(180deg, #1677ff 0%, #4096ff 100%);
border-radius: 2px;
transition: all 0.3s ease;
}
.info-panel-item:hover {
background: linear-gradient(90deg, #f0f7ff 0%, transparent 100%);
padding-left: 10px;
padding-right: 16px;
margin-left: -12px;
margin-right: -16px;
border-radius: 8px;
border-bottom-color: transparent;
}
.info-panel-item:hover::before {
width: 3px;
height: 60%;
}
.info-panel-label {
color: rgba(0, 0, 0, 0.45);
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.info-panel-value {
color: rgba(0, 0, 0, 0.88);
font-size: 15px;
font-weight: 500;
word-break: break-all;
line-height: 1.6;
}
/* 操作按钮区 */
.info-panel-actions {
padding: 24px 32px;
background: linear-gradient(to bottom, #fafafa 0%, #f5f5f5 100%);
border-top: 2px solid var(--border-color);
position: relative;
}
/* 操作区域顶部装饰线 */
.info-panel-actions::before {
content: '';
position: absolute;
top: -2px;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, #1677ff 0%, transparent 50%, #1677ff 100%);
opacity: 0.3;
}

View File

@ -1,58 +0,0 @@
import { Row, Col, Space, Button } from 'antd'
import './InfoPanel.css'
/**
* 信息展示面板组件
* @param {Object} props
* @param {Object} props.data - 数据源
* @param {Array} props.fields - 字段配置数组
* @param {Array} props.actions - 操作按钮配置(可选)
* @param {Array} props.gutter - Grid间距配置
*/
function InfoPanel({ data, fields = [], actions = [], gutter = [24, 16] }) {
if (!data) {
return null
}
return (
<div className="info-panel">
<Row gutter={gutter}>
{fields.map((field) => {
const value = data[field.key]
const displayValue = field.render ? field.render(value, data) : value
return (
<Col key={field.key} span={field.span || 6}>
<div className="info-panel-item">
<div className="info-panel-label">{field.label}</div>
<div className="info-panel-value">{displayValue}</div>
</div>
</Col>
)
})}
</Row>
{/* 可选的操作按钮区 */}
{actions && actions.length > 0 && (
<div className="info-panel-actions">
<Space size="middle">
{actions.map((action) => (
<Button
key={action.key}
type={action.type || 'default'}
icon={action.icon}
disabled={action.disabled}
danger={action.danger}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</Space>
</div>
)}
</div>
)
}
export default InfoPanel

View File

@ -13,6 +13,8 @@ import './LargeMarkdownEditor.css'
// 复用 ByteMD 底层的 codemirror-ssr,为超大文档提供带 Markdown 源码着色的纯文本编辑器。
// 不引入新依赖,CodeMirror 5 的视口渲染让超大文档编辑保持流畅。
// 注意:codemirror-ssr 导出的扩展注册函数恰好以 use 开头,它们不是 React Hook
/* eslint-disable react-hooks/rules-of-hooks */
function createCodeMirror() {
const codemirror = factory()
usePlaceholder(codemirror)
@ -21,6 +23,7 @@ function createCodeMirror() {
useMarkdown(codemirror)
useGfm(codemirror)
useContinuelist(codemirror)
/* eslint-enable react-hooks/rules-of-hooks */
return codemirror
}

Some files were not shown because too many files have changed in this diff Show More