解决编辑模式的问题

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

5
.gitignore vendored
View File

@ -13,6 +13,7 @@ Thumbs.db
# Project storage (user uploaded files) # Project storage (user uploaded files)
storage/ storage/
backup/ backup/
backups/
# Documentation files (可能是临时的) # Documentation files (可能是临时的)
*.md.backup *.md.backup
@ -32,8 +33,12 @@ logs/
*.tmp *.tmp
*.temp *.temp
# 本地运行产物(scripts/start.sh 生成的 pid / 日志)
.run/
# Local models # Local models
backend/models backend/models
# AI # AI
.gemini-clipboard/ .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"> <div align="center">
一个轻量级、高性能的团队协作文档管理平台 轻量、可自托管的团队文档中心:文件系统存储内容,数据库管理权限,内置 AI 知识库问答。
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) v1.0.0 · FastAPI + React 18 + MySQL 8 + Redis
[![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/)
</div> </div>
--- ---
## ✨ 特性 ## ✨ 核心特性
- 🚀 **高性能**: FastAPI 异步后端 + React 18 前端 - 📁 **文件即真理**:文档正文以 Markdown 文件形式存放在磁盘(`storage/projects/<uuid>/…`),数据库只保存权限与元数据,备份/迁移只需拷目录。
- 📁 **文件存储**: 数据库管理权限 + 文件系统存储内容 - 📝 **Markdown 编辑**:基于 ByteMD 的编辑器,支持 **编辑 / 分栏 / 预览** 三态切换,右侧悬浮目录(TOC)在纯编辑模式下同样可用;支持 GFM、代码高亮、Frontmatter、emoji、图片上传。
- 🔐 **权限控制**: 完整的 RBAC 权限体系 - 🌲 **无限层级目录树**:文件/文件夹创建、重命名、拖拽移动、排序。
- 📝 **Markdown 编辑**: 实时预览、图片上传 - 👥 **团队协作**:项目成员与项目内角色(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+ | 框架 | React 18 + React Router v6 |
- **缓存**: Redis | 构建 | Vite 5(`manualChunks` 分包、全部路由 lazy 加载) |
- **认证**: JWT (PyJWT) | UI | Ant Design 5 + 自研设计令牌(无 Tailwind / postcss) |
- **文件**: aiofiles (异步 I/O) | 状态 | Zustand + Axios 统一封装 |
| Markdown | ByteMD(`@bytemd/react` + gfm/highlight/frontmatter/breaks/gemoji)+ react-markdown 渲染 |
### 前端技术栈 | 其它 | react-pdf / pdfjs-dist、react-virtuoso、antd-img-crop |
- **框架**: React 18
- **构建工具**: Vite
- **UI 组件**: Ant Design 5
- **路由**: React Router v6
- **状态管理**: Zustand
- **Markdown**: @uiw/react-md-editor
- **样式**: Tailwind CSS
---
## 📦 项目结构 ## 📦 项目结构
``` ```
NEX Docus/ NexDocus/
├── backend/ # FastAPI 后端服务 ├── backend/ # FastAPI 后端
│ ├── app/ │ ├── app/
│ │ ├── api/v1/ # API 路由(v1) │ │ ├── api/v1/ # API 路由(认证/项目/文件/检索/对话/分享/系统…)
│ │ ├── core/ # 核心配置 │ │ ├── core/ # 配置、数据库、安全、依赖注入、幂等迁移
│ │ ├── models/ # 数据库模型 │ │ ├── models/ # SQLAlchemy 模型(18 张表)
│ │ ├── schemas/ # Pydantic Schemas │ │ ├── schemas/ # Pydantic Schema
│ │ ├── services/ # 业务逻辑服务 │ │ ├── services/ # 业务逻辑(存储、检索、向量化、RAG、Git、导出…)
│ │ └── middleware/ # 中间件 │ │ └── mcp/ # MCP Streamable HTTP 接入(凭证鉴权 + 工具注册)
│ ├── scripts/ # 初始化脚本 │ ├── scripts/ # 数据库初始化脚本(随代码走,Docker 构建上下文需要)
│ ├── tests/ # pytest 用例
│ └── main.py # 应用入口 │ └── main.py # 应用入口
│ │
├── frontend/ # React 前端应用 ├── frontend/ # React 前端
│ ├── src/ │ └── src/
│ │ ├── api/ # API 封装 │ ├── api/ # 接口封装
│ │ ├── components/ # 通用组件 │ ├── components/ # 通用组件(Feedback 统一提示、MainLayout…)
│ │ ├── pages/ # 页面组件 │ ├── data/ # 静态配置数据
│ │ ├── stores/ # 状态管理 │ ├── pages/ # 页面(全部 lazy 加载)
│ │ └── utils/ # 工具函数 │ ├── stores/ # Zustand
│ └── package.json │ ├── styles/ # design-tokens.css 等全局样式
│ ├── theme/ # antd 主题(明/暗)
│ └── utils/
│ │
├── DATABASE.md # 数据库设计文档 ├── scripts/ # 运维/开发脚本(start / stop / deploy)
├── PROJECT.md # 技术方案文档 ├── docs/ # 全部文档(见 docs/README.md)
├── QUICKSTART.md # 快速启动指南 ├── storage/ # 运行期文件存储(已 gitignore)
└── IMPLEMENTATION_PLAN.md # 实施计划 ├── 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 ```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 ```bash
cd backend ./scripts/start.sh --backend # 只启动后端
source venv/bin/activate ./scripts/start.sh --frontend # 只启动前端
pip install -r requirements.txt ./scripts/start.sh --install # 只准备环境,不启动服务
python main.py ./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 ```bash
cd frontend cp .env.example .env # 按注释修改密码/端口/存储路径
npm install ./scripts/deploy.sh init # 生成配置、构建镜像、初始化数据库
npm run dev ./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) | 脚本清单与约定 |
### 项目管理 ## 🧪 开发与质量
- ✅ 创建/编辑/删除项目
- ✅ 项目成员管理
- ✅ 访问权限控制
- ✅ 项目归档
### 文档编辑 ```bash
- ✅ Markdown 实时预览 # 前端:静态检查与构建
- ✅ 无限层级目录 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` - 用户表 - 密码 bcrypt 存储;JWT 过期时间由 `ACCESS_TOKEN_EXPIRE_MINUTES` 控制。
- `roles` - 角色表 - 文件路径全部经过规范化校验,拒绝路径穿越;上传大小与类型受限。
- `user_roles` - 用户角色关联 - `DEBUG=True` 会打印全量 SQL,**生产必须关闭**。
- `system_menus` - 系统菜单 - `SECRET_KEY`、数据库/Redis 密码、Git Token 必须由环境注入,禁止提交到仓库;`backend/.env`、`.env` 已在 `.gitignore` 中。
- `role_menus` - 角色菜单授权 - 分享链接的访问密码当前为明文存储于 `share_links.access_pass`,见发布报告 P2 项。
- `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 文档编辑
- ✅ 文件系统管理
- ✅ 权限控制体系
- ✅ 团队协作功能
---
## 📄 许可证 ## 📄 许可证
Copyright © 2023 Mula.liu Copyright © 2026 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/)
--- ---

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 | Web 框架 | FastAPI + Uvicorn |
- **认证**: JWT (python-jose) | ORM | SQLAlchemy 2.0 异步(aiomysql),Alembic 迁移 |
- **密码加密**: bcrypt (passlib) | 数据库 / 缓存 | MySQL 8(utf8mb4)/ Redis |
- **文件处理**: aiofiles (异步文件 I/O) | 认证 | JWT(python-jose)+ bcrypt(passlib) |
| 全文检索 | Whoosh 3 + jieba 分词 |
| 向量检索 | ZVec(远端 Embedding 或本地 Sentence-Transformers) |
| 文件与导出 | aiofiles、自研 Markdown/PDF 导出服务 |
| Git 同步 | 通过命令行调用 `git`(主机需自行安装) |
| 测试 | pytest + pytest-asyncio |
## 项目结构 ## 目录结构
``` ```
backend/ backend/
├── app/ ├── app/
│ ├── api/ │ ├── api/v1/ # HTTP 路由(前缀 /api/v1),__init__.py 汇总注册
│ │ └── v1/ # API 路由(v1 版本) │ ├── core/ # config / database / deps / security / enums / migrations / redis_client
│ │ ├── auth.py # 用户认证 │ ├── mcp/ # MCP server 与请求上下文(挂载在 /mcp)
│ │ ├── projects.py # 项目管理 │ ├── models/ # SQLAlchemy 模型(__init__.py 必须导入全部模型,见下方陷阱)
│ │ └── files.py # 文件系统 │ ├── schemas/ # Pydantic Schema 与统一响应包装
│ ├── core/ # 核心配置 │ └── services/ # 业务逻辑:project/file/search/rag/zvec/git/vectorization/notification/log/storage/llm…
│ │ ├── config.py # 应用配置 ├── scripts/ # init_db.py(建表+种子数据)、generate_password.py,详见 scripts/README.md
│ │ ├── database.py # 数据库连接 ├── tests/ # pytest 用例
│ │ ├── security.py # 安全工具 ├── models/ # 本地向量模型目录(gitignore,需自行放置 HF 模型,如 m3e-small)
│ │ └── deps.py # 依赖注入 ├── main.py # 应用入口:/api/v1 路由、/mcp 挂载、/ 与 /health
│ ├── models/ # 数据库模型 ├── requirements.txt # 运行依赖
│ ├── schemas/ # Pydantic Schemas ├── requirements-dev.txt # 开发/测试依赖(含 pytest)
│ ├── services/ # 业务逻辑 ├── pytest.ini # testpaths=tests, pythonpath=.
│ ├── middleware/ # 中间件 └── .env # 本地配置(不入库;键名见下表)
│ └── utils/ # 工具函数
├── scripts/ # 脚本文件
│ ├── init_database.sql # 数据库初始化 SQL
│ └── init_db.py # 数据库初始化 Python 脚本
├── tests/ # 测试文件
├── main.py # 应用入口
├── requirements.txt # 依赖包
└── .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 ```bash
# 激活虚拟环境 cd backend
source venv/bin/activate # macOS/Linux python3 -m venv venv
# 或 ./venv/bin/pip install -r requirements-dev.txt # 含运行依赖 + pytest
venv\Scripts\activate # Windows cp .env.example .env # 若无,按 docs/quickstart.md 的键表创建
# 安装依赖 ./venv/bin/python scripts/init_db.py # 建表 + 种子数据(幂等)
pip install -r requirements.txt ./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 ```bash
# 方式一:使用 SQL 脚本(推荐) cd backend && ./venv/bin/python -m pytest
mysql -h10.100.51.51 -uroot -pUnis@321 < scripts/init_database.sql
# 方式二:使用 Python 脚本(仅创建表结构)
python scripts/init_db.py
``` ```
### 4. 启动服务 覆盖范围(v1.0.0):项目权限与角色归一化、Git 服务命令构造、搜索服务、模型与向量配置、RAG 引用解析。**前端目前没有自动化测试**,界面回归需手工验证,重点清单见 [`docs/sdd/releases/v1.0.0.md`](../docs/sdd/releases/v1.0.0.md)。
```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

View File

@ -18,6 +18,7 @@ from app.models.project import Project, ProjectMember
from app.models.log import OperationLog from app.models.log import OperationLog
from app.core.enums import OperationType, ResourceType from app.core.enums import OperationType, ResourceType
from app.schemas.response import success_response from app.schemas.response import success_response
from app.services.project_service import normalize_project_role
router = APIRouter() router = APIRouter()
@ -167,7 +168,7 @@ async def get_personal_stats(
"id": project.id, "id": project.id,
"name": project.name, "name": project.name,
"description": project.description, "description": project.description,
"role": member.role, "role": normalize_project_role(member.role),
"joined_at": member.joined_at.isoformat() if member.joined_at else None, "joined_at": member.joined_at.isoformat() if member.joined_at else None,
} }
for project, member in recent_shared_projects_rows 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.user import User
from app.models.log import OperationLog from app.models.log import OperationLog
from app.models.share import ShareLink from app.models.share import ShareLink
from app.models.project import Project, ProjectMember
from app.schemas.file import ( from app.schemas.file import (
FileTreeNode, FileTreeNode,
FileSaveRequest, FileSaveRequest,
@ -40,45 +39,6 @@ from app.core.enums import OperationType
router = APIRouter() 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: def annotate_shared_files(tree: List[FileTreeNode], shared_paths: set[str]) -> None:
"""为文件树节点补充分享状态""" """为文件树节点补充分享状态"""
for node in tree: for node in tree:
@ -114,19 +74,8 @@ async def get_project_tree(
} }
annotate_shared_files(tree, shared_paths) annotate_shared_files(tree, shared_paths)
# 获取当前用户角色 # user_role 直接来自 require_project_read_access(已做历史大小写归一化),
user_role = "owner" # 默认是所有者 # 不要在这里再查一次成员表:重复查询曾导致前端拿到 VIEWER 这类历史值后判断失效。
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
return success_response(data={ return success_response(data={
"tree": tree, "tree": tree,

View File

@ -1,6 +1,8 @@
""" """
通知管理 API (Redis版) 通知管理 API (Redis版)
""" """
import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from typing import List, Union from typing import List, Union
from datetime import datetime from datetime import datetime
@ -15,6 +17,8 @@ from app.schemas.notification import (
from app.schemas.response import success_response from app.schemas.response import success_response
from app.services.notification_service import notification_service from app.services.notification_service import notification_service
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -58,7 +62,7 @@ async def get_notifications(
} }
except Exception as e: except Exception as e:
# 降级处理,防止 500 # 降级处理,防止 500
print(f"Error fetching notifications: {e}") logger.warning("读取通知列表失败,已降级为空列表: %s", e, exc_info=True)
return { return {
"code": 200, "code": 200,
"message": "success", "message": "success",
@ -78,7 +82,7 @@ async def get_unread_count(
count = await notification_service.get_unread_count(current_user.id) count = await notification_service.get_unread_count(current_user.id)
return success_response(data={"unread_count": count}) return success_response(data={"unread_count": count})
except Exception as e: 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}) 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) counts = await notification_service.get_unread_count_by_project(current_user.id)
return success_response(data={"unread_by_project": counts}) return success_response(data={"unread_by_project": counts})
except Exception as e: 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": {}}) 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) count = await notification_service.mark_project_read(current_user.id, project_id)
return success_response(data={"marked": count}, message="项目通知已标记为已读") return success_response(data={"marked": count}, message="项目通知已标记为已读")
except Exception as e: except Exception as e:
print(f"Error marking project read: {e}") logger.warning("标记项目通知已读失败: %s", e, exc_info=True)
return success_response(message="操作完成") 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.log_service import log_service
from app.services.git_service import git_service from app.services.git_service import git_service
from app.services.notification_service import notification_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 from app.core.enums import OperationType, ResourceType
router = APIRouter() router = APIRouter()
@ -281,31 +286,20 @@ async def get_project(
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""获取项目详情""" """获取项目详情"""
# 查询项目 # 权限与角色统一走 project_service,避免与其他接口出现两套判定
result = await db.execute(select(Project).where(Project.id == project_id)) project, user_role = await require_project_read_access(
project = result.scalar_one_or_none() db, project_id, current_user, allow_public=True
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
) )
)
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 project.visit_count += 1
await db.commit() await db.commit()
# updated_at 由数据库 onupdate 生成,commit 后该列处于过期状态,
# 必须用异步 refresh 取回,否则序列化时会在异步上下文里触发同步 IO(MissingGreenlet -> 500)
await db.refresh(project)
project_data = ProjectResponse.from_orm(project) project_data = serialize_project(project, user_role=user_role)
return success_response(data=project_data.dict()) return success_response(data=project_data)
@router.put("/{project_id}", response_model=dict) @router.put("/{project_id}", response_model=dict)
@ -690,7 +684,7 @@ async def update_project_member_role(
if not target_member: if not target_member:
raise HTTPException(status_code=404, detail="该用户不是项目成员") 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 target_member.role = member_in.role
await db.commit() await db.commit()
await db.refresh(target_member) 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.schemas.response import success_response
from app.services.log_service import log_service from app.services.log_service import log_service
from app.services.pdf_service import pdf_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.search_service import search_service
from app.services.storage import storage_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() member = member_result.scalar_one_or_none()
if not member: if not member:
raise HTTPException(status_code=403, detail="无权访问该项目") 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: 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_NAME: str = "NEX Docus"
APP_VERSION: str = "0.9.9" APP_VERSION: str = "1.0.0"
DEBUG: bool = True DEBUG: bool = True
# 服务器配置 # 服务器配置
@ -84,6 +84,32 @@ class Settings(BaseSettings):
env_file = ".env" env_file = ".env"
case_sensitive = True 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() 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.project import Project, ProjectMember, ProjectMemberRole
from app.models.document import DocumentMeta from app.models.document import DocumentMeta
from app.models.document_vector import DocumentVector 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.share import ShareLink
from app.models.log import OperationLog from app.models.log import OperationLog
from app.models.mcp_bot import MCPBot from app.models.mcp_bot import MCPBot
@ -27,6 +29,8 @@ __all__ = [
"ProjectMemberRole", "ProjectMemberRole",
"DocumentMeta", "DocumentMeta",
"DocumentVector", "DocumentVector",
"ProjectGitRepo",
"Notification",
"ShareLink", "ShareLink",
"OperationLog", "OperationLog",
"MCPBot", "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 import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from app.core.config import settings from app.core.config import settings
from app.core.redis_client import init_redis, close_redis from app.core.redis_client import init_redis, close_redis
@ -19,9 +20,15 @@ except RuntimeError:
mcp_session_manager = None mcp_session_manager = None
logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""应用生命周期管理""" """应用生命周期管理"""
# 敏感配置自检:命中占位值时告警(不阻断启动,避免影响存量部署)
for warning in settings.security_warnings():
logger.warning("[安全自检] %s", warning)
# 启动时补齐数据库新增列(幂等),避免存量库缺少新字段 # 启动时补齐数据库新增列(幂等),避免存量库缺少新字段
await migrate_schema() await migrate_schema()
# 启动时初始化 Redis # 启动时初始化 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_name="知识库空间",
menu_code="knowledge", menu_code="knowledge",
menu_type=0, menu_type=0,
path="/knowledge", path="/chat",
icon="BookOutlined", icon="BookOutlined",
sort_order=4, sort_order=4,
visible=1, visible=1,
@ -220,7 +220,7 @@ async def init_menus():
menu_name="我的知识库", menu_name="我的知识库",
menu_code="knowledge:my", menu_code="knowledge:my",
menu_type=1, menu_type=1,
path="/knowledge", path="/chat",
component="MyKnowledge", component="MyKnowledge",
icon="CommentOutlined", icon="CommentOutlined",
sort_order=1, sort_order=1,
@ -348,7 +348,7 @@ async def init_admin_user():
# 从环境变量获取管理员信息 # 从环境变量获取管理员信息
admin_username = os.getenv("ADMIN_USERNAME", "admin") admin_username = os.getenv("ADMIN_USERNAME", "admin")
admin_password = os.getenv("ADMIN_PASSWORD", "admin@123") 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", "系统管理员") admin_nickname = os.getenv("ADMIN_NICKNAME", "系统管理员")
async with async_session() as session: 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 ( from app.services.project_service import (
normalize_project_role, normalize_project_role,
require_project_read_access,
require_project_roles, require_project_roles,
require_project_write_access, require_project_write_access,
) )
@ -69,6 +70,28 @@ class ProjectPermissionsTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(context.exception.status_code, 403) 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): async def test_admin_only_permission_rejects_editor(self):
db = _RecordingDB([ db = _RecordingDB([
self.project, 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: services:
# MySQL 数据库 # MySQL 数据库
mysql: mysql:
@ -74,6 +72,7 @@ services:
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-Admin@123456} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-Admin@123456}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
- ADMIN_NICKNAME=${ADMIN_NICKNAME:-系统管理员} - ADMIN_NICKNAME=${ADMIN_NICKNAME:-系统管理员}
- DEFAULT_USER_PASSWORD=${DEFAULT_USER_PASSWORD:-User@123456}
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
volumes: volumes:
- ${STORAGE_PATH:-./storage}:/data/nex_docus_store - ${STORAGE_PATH:-./storage}:/data/nex_docus_store
@ -96,8 +95,6 @@ services:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args:
- VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8000}
container_name: nex-docus-frontend container_name: nex-docus-frontend
restart: unless-stopped restart: unless-stopped
environment: 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 仓库集成 │ └── git.md # 项目 Git 仓库集成
├── releases/ ├── releases/
│ ├── README.md # 公开版本与资产索引 │ ├── README.md # 公开版本与资产索引
│ ├── v0.9.6.md # v0.9.6 历史升级记录(整合自 docs/) │ ├── v1.0.0.md # 当前发布(首个正式版本,含验证边界与上线清单)
│ └── v0.9.9.md # 当前基线发布(对齐 git) │ ├── v0.9.9.md # 历史发布记录
│ └── v0.9.6.md # 历史升级记录(整合自 docs/)
└── specs/ └── specs/
├── README.md # 功能规格索引 ├── README.md # 功能规格索引
├── _template/ # 新规格模板 ├── _template/ # 新规格模板
@ -38,11 +39,12 @@ docs/sdd/
## 当前状态(Status) ## 当前状态(Status)
- **SDD 文档状态**:基线(Baseline)· 对齐 git 当前版本 v0.9.9 - **SDD 文档状态**:v1.0.0 发布评审(Release Candidate)
- **适用代码基线**:当前仓库(backend + frontend + docker-compose 部署) - **适用代码基线**:`ba80d28 fix project role permission`(main)+ 发布前整改(未提交)
- **规格覆盖**:11 个功能单元(DV-0001 ~ DV-0011);价值主张 PO-1~5;架构决策 ADR-0001~0008 - **规格覆盖**:11 个功能单元(DV-0001 ~ DV-0011);价值主张 PO-1~5;架构决策 ADR-0001~0008
- **已知整改项**:见 governance「开放问题」与各规格 tasks.md 中的“待整改/待决”标记(如敏感日志脱敏 TS-12、Compose v2 官方化 TS-13) - **验证边界**:已复验项(pytest 40 passed / eslint 0 error / vite build / 编辑器与权限的浏览器实测)与**未验证项**(Docker 端到端、MCP 实连、Git 真实远端同步)统一登记在 [releases/v1.0.0.md](releases/v1.0.0.md)
- **版本基线**:以 git 为准(当前 v0.9.9;见 releases/README.md 与 v0.9.9.md 版本对齐说明;代码字段 1.0.0 为遗留占位) - **已知整改项**:以 [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 中心**汇总并指向**仓库既有的详细材料,而非重复复制全部内容: SDD 中心**汇总并指向**仓库既有的详细材料,而非重复复制全部内容:
- 数据库细节 → 仓库根 `DATABASE.md`(SDD 只保留 ER 概览与规格化的链路表) - 数据库细节 → [`docs/database.md`](../database.md)(SDD 只保留 ER 概览与规格化的链路表)
- 部署运维 → `DEPLOY.md`、`README_DOCKER.md`、`CHANGELOG_DEPLOY.md`(及已并入的 docs/DOCKER_DOCS_SETUP 历史,见 archive.md) - 部署运维 → [`docs/deploy/README.md`](../deploy/README.md)、[配置变更日志](../deploy/changelog.md)(原 `DEPLOY.md`/`README_DOCKER.md`/`CHANGELOG_DEPLOY.md` 已合并去重)
- 历史变更 → `docs/UPGRADE_v0.9.6.md`、`docs/MIGRATION.md`(已整合/弃置,见 archive.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-* 已迁入) - 结构规范 → `architecture/standards/`(原 docs/code-structure-* 已迁入)
> 本中心是入口与追踪层;具体逐表 DDL、逐配置项说明等细节仍以被指向的源文档为准。 > 本中心是入口与追踪层;具体逐表 DDL、逐配置项说明等细节仍以被指向的源文档为准。

View File

@ -20,7 +20,7 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| OP-1 | Alembic vs 幂等 ALTER | 采用幂等 ALTER(`migrations.py`) | 评估引入 Alembic 以支撑大规模结构变更 | | OP-1 | Alembic vs 幂等 ALTER | 采用幂等 ALTER(`migrations.py`) | 评估引入 Alembic 以支撑大规模结构变更 |
| OP-2 | 敏感日志 | `security.py`/`deps.py` 记录 SECRET_KEY 前缀/JWT 载荷 | 脱敏,见 OI-1 | | 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-4 | 测试覆盖 | 仅 3 个后端单测 | 增加集成/E2E,见 OI-3 |
| OP-5 | 实时协同编辑 | 明确不在此版本做(vision 边界) | 另立项评估 OT/CRDT | | OP-5 | 实时协同编辑 | 明确不在此版本做(vision 边界) | 另立项评估 OT/CRDT |
| OP-6 | 多实例/高可用 | 单机部署,无状态服务仅后端(Redis/DB 独立) | 若需要再评估 | | 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 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:` 仅产生弃用警告,不影响功能。 - `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) # 阶段性路线图(Roadmap)
> 基于仓库现状(IMPLEMENTATION_PLAN.md 三阶段已基本完成)与 SDD 梳理结果,重新组织为阶段化路线图。状态为 SDD 核对后的推断。 > 基于仓库现状(原 IMPLEMENTATION_PLAN.md 三阶段已基本完成,现已归档到 [archive/IMPLEMENTATION_PLAN.md](../../archive/IMPLEMENTATION_PLAN.md))与 SDD 梳理结果,重新组织为阶段化路线图。状态为 SDD 核对后的推断。
## 阶段一:MVP 文档平台(已完成) ## 阶段一:MVP 文档平台(已完成)
- 认证与会话(DV-0001) - 认证与会话(DV-0001)

View File

@ -6,20 +6,22 @@
- 版本号语义化(MAJOR.MINOR.PATCH)。 - 版本号语义化(MAJOR.MINOR.PATCH)。
- 发布前必须:对应 DV verification.md 证据通过;ADR 无未决的重大分歧。 - 发布前必须:对应 DV verification.md 证据通过;ADR 无未决的重大分歧。
- 发布文件中须给出「验证边界」:哪些 DV 被覆盖、如何复验、已知限制。 - 发布文件中须给出「验证边界」:哪些 DV 被覆盖、如何复验、已知限制。
- 部署相关变更同步到 `DEPLOY.md` / `CHANGELOG_DEPLOY.md`。 - 部署/配置相关变更同步到 [`docs/deploy/README.md`](../../deploy/README.md) 与 [配置变更日志](../../deploy/changelog.md)。
- **版本以 git 为准**:发布必须对应一个 git 版本标记(提交信息中的 vX.Y.Z 或 tag);代码内版本字段应与 git 标记一致。 - **版本以 git 为准**:发布必须对应一个 git 版本标记(优先使用 tag;历史提交以提交信息中的 vX.Y.Z 为准);代码内版本字段应与 git 标记一致。
## 版本列表(对齐 git) ## 版本列表(对齐 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 持久化(历史) | 历史发布记录 | | 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(当前基线) - **版本**:v0.9.9(当前基线)
@ -19,7 +21,7 @@
## 验证边界 ## 验证边界
- 自动化测试:`backend/tests/test_chat_citations.py`、`test_model_and_vector_configuration.py`、`test_search_service.py`。 - 自动化测试:`backend/tests/test_chat_citations.py`、`test_model_and_vector_configuration.py`、`test_search_service.py`。
- 运行验证:`/health` 返回 healthy;Swagger `/docs` 可用。 - 运行验证:`/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)。 - 已知限制:单机部署;无实时协同编辑;测试覆盖待增强(OI-3)。
## 已知问题 ## 已知问题
@ -27,9 +29,9 @@
| --- | --- | --- | | --- | --- | --- |
| OI-1 | 敏感日志脱敏 | 待整改 | | OI-1 | 敏感日志脱敏 | 待整改 |
| OI-2 | Compose v2 官方化 | 待整改 | | 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`)。 - 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 均为遗留占位,与 git 不符。 - 撰写本文时**尚无 v1.0.0** 的 git 版本,代码 `APP_VERSION=1.0.0`、`package.json version=1.0.0` 与文档中的 v1.0.0/v1.0.1 均为遗留占位。
- 建议:下次打 tag 时以 v0.9.9 为基线;若计划升级 v1.0.0 则应显式建 tag 并同步代码字段。 - **后续处置(已完成)**: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)。 涉及 `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} - POST /api/v1/auth/{register,login,logout}

View File

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

View File

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

View File

@ -7,7 +7,7 @@
- 代码/模块组织。 - 代码/模块组织。
## 数据模型 ## 数据模型
- 涉及表/字段(指向 DATABASE.md 细节)。 - 涉及表/字段(指向 [`docs/database.md`](../../../database.md) 细节)。
## 接口设计 ## 接口设计
- API 端点 / 交互。 - 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", "name": "nex-docus-frontend",
"private": true, "private": true,
"version": "0.9.9", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "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": { "dependencies": {
"@ant-design/icons": "^5.2.6", "@ant-design/icons": "^5.2.6",
@ -42,16 +43,15 @@
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17", "@types/react-dom": "^18.2.17",
"@vitejs/plugin-legacy": "^5.4.3",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0", "eslint": "^8.55.0",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"terser": "^5.46.0", "terser": "^5.46.0",
"vite": "^5.0.8" "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 { 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 zhCN from 'antd/locale/zh_CN'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn' import 'dayjs/locale/zh-cn'
import useThemeStore from '@/stores/themeStore' 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 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 MainLayout from '@/components/MainLayout/MainLayout'
import ProtectedRoute from '@/components/ProtectedRoute'
import '@/App.css' import '@/App.css'
dayjs.locale('zh-cn') 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() { function RedirectToDocs() {
const { projectId } = useParams() const { projectId } = useParams()
@ -47,11 +52,7 @@ function App() {
const { isDarkMode } = useThemeStore() const { isDarkMode } = useThemeStore()
useEffect(() => { useEffect(() => {
if (isDarkMode) { document.body.classList.toggle('dark', isDarkMode)
document.body.classList.add('dark')
} else {
document.body.classList.remove('dark')
}
}, [isDarkMode]) }, [isDarkMode])
return ( return (
@ -59,9 +60,17 @@ function App() {
locale={zhCN} locale={zhCN}
theme={{ theme={{
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm, 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> <BrowserRouter>
<Suspense fallback={<PageLoading />}>
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/share/project/:shareCode" element={<ProjectSharePage />} /> <Route path="/share/project/:shareCode" element={<ProjectSharePage />} />
@ -93,7 +102,10 @@ function App() {
<Route path="/" element={<Navigate to="/projects" replace />} /> <Route path="/" element={<Navigate to="/projects" replace />} />
</Routes> </Routes>
</Suspense>
</BrowserRouter> </BrowserRouter>
</FeedbackBridge>
</AntdApp>
</ConfigProvider> </ConfigProvider>
) )
} }

View File

@ -83,7 +83,7 @@ export const sendChatMessageStream = async (sessionId, message, handlers = {}) =
} }
} }
while (true) { for (;;) {
const { value, done } = await reader.read() const { value, done } = await reader.read()
if (done) break if (done) break
buffer += decoder.decode(value, { stream: true }) 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 源码着色的纯文本编辑器。 // 复用 ByteMD 底层的 codemirror-ssr,为超大文档提供带 Markdown 源码着色的纯文本编辑器。
// 不引入新依赖,CodeMirror 5 的视口渲染让超大文档编辑保持流畅。 // 不引入新依赖,CodeMirror 5 的视口渲染让超大文档编辑保持流畅。
// 注意:codemirror-ssr 导出的扩展注册函数恰好以 use 开头,它们不是 React Hook
/* eslint-disable react-hooks/rules-of-hooks */
function createCodeMirror() { function createCodeMirror() {
const codemirror = factory() const codemirror = factory()
usePlaceholder(codemirror) usePlaceholder(codemirror)
@ -21,6 +23,7 @@ function createCodeMirror() {
useMarkdown(codemirror) useMarkdown(codemirror)
useGfm(codemirror) useGfm(codemirror)
useContinuelist(codemirror) useContinuelist(codemirror)
/* eslint-enable react-hooks/rules-of-hooks */
return codemirror return codemirror
} }

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