解决编辑模式的问题
parent
ba80d280d1
commit
6831bd4831
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export function apply(ctx) {
|
||||
// Host half is intentionally empty: this plugin only contributes browser UI.
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
15
.env.example
15
.env.example
|
|
@ -33,11 +33,10 @@ BACKEND_PORT=8000
|
|||
FRONTEND_PORT=8080
|
||||
|
||||
# ==================== 前端配置 ====================
|
||||
# API 基础 URL(根据实际部署方式选择)
|
||||
# 方式1(推荐):使用 Nginx 反向代理,前后端同域名同端口
|
||||
VITE_API_BASE_URL=/api
|
||||
# 方式2:直接访问后端端口(需开放后端端口,可能有跨域问题)
|
||||
# VITE_API_BASE_URL=http://yourdomain.com:8001
|
||||
# 前端始终请求同源相对路径 /api/v1:
|
||||
# - 开发环境由 vite 的 server.proxy 转发到后端
|
||||
# - Docker 部署由前端容器内的 Nginx 反代(见 frontend/nginx.conf,上游为 compose 服务 backend)
|
||||
# 因此无需配置前端侧的 API 地址;如需跨域直连后端,请自行修改 frontend/nginx.conf 或部署自己的网关。
|
||||
|
||||
# ==================== 存储配置 ====================
|
||||
# 文件存储路径(宿主机路径,用于存储项目文档和上传文件)
|
||||
|
|
@ -47,8 +46,12 @@ STORAGE_PATH=./storage
|
|||
# 初始管理员账号信息
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=Admin@123456
|
||||
ADMIN_EMAIL=admin@unisspace.com
|
||||
# 首次初始化时创建的管理员邮箱(占位值,部署后请在「个人设置」中改为真实邮箱)
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_NICKNAME=系统管理员
|
||||
|
||||
# 管理员在系统里新建用户时的初始密码(务必改为随机值;部署后立即修改管理员密码)
|
||||
DEFAULT_USER_PASSWORD=User@123456
|
||||
# 仅当 LLM/Embedding 服务使用自签名证书时开启(生产环境保持 false)
|
||||
DISABLE_SSL_VERIFY=false
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ Thumbs.db
|
|||
# Project storage (user uploaded files)
|
||||
storage/
|
||||
backup/
|
||||
backups/
|
||||
|
||||
# Documentation files (可能是临时的)
|
||||
*.md.backup
|
||||
|
|
@ -32,8 +33,12 @@ logs/
|
|||
*.tmp
|
||||
*.temp
|
||||
|
||||
# 本地运行产物(scripts/start.sh 生成的 pid / 日志)
|
||||
.run/
|
||||
|
||||
# Local models
|
||||
backend/models
|
||||
|
||||
# AI
|
||||
.gemini-clipboard/
|
||||
.claude/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
341
DATABASE.md
341
DATABASE.md
|
|
@ -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
330
DEPLOY.md
|
|
@ -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 部署方案
|
||||
- ✨ 一键初始化和升级
|
||||
- ✨ 数据库备份恢复
|
||||
- ✨ 国内镜像加速
|
||||
- ✨ 完善的文档和脚本
|
||||
|
||||
---
|
||||
|
||||
**祝部署顺利!** 🎉
|
||||
17
DEPLOYEE.md
17
DEPLOYEE.md
|
|
@ -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)
|
||||
230
QUICKSTART.md
230
QUICKSTART.md
|
|
@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**祝你使用愉快!🎊**
|
||||
330
README.md
330
README.md
|
|
@ -2,263 +2,177 @@
|
|||
|
||||
<div align="center">
|
||||
|
||||
一个轻量级、高性能的团队协作文档管理平台
|
||||
轻量、可自托管的团队文档中心:文件系统存储内容,数据库管理权限,内置 AI 知识库问答。
|
||||
|
||||
[](LICENSE)
|
||||
[](https://www.python.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://fastapi.tiangolo.com/)
|
||||
v1.0.0 · FastAPI + React 18 + MySQL 8 + Redis
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 特性
|
||||
## ✨ 核心特性
|
||||
|
||||
- 🚀 **高性能**: FastAPI 异步后端 + React 18 前端
|
||||
- 📁 **文件存储**: 数据库管理权限 + 文件系统存储内容
|
||||
- 🔐 **权限控制**: 完整的 RBAC 权限体系
|
||||
- 📝 **Markdown 编辑**: 实时预览、图片上传
|
||||
- 👥 **团队协作**: 项目成员管理、角色分配
|
||||
- 🌲 **目录树**: 无限层级目录支持
|
||||
- 💾 **数据安全**: 文件即真理、易于备份和迁移
|
||||
- 📁 **文件即真理**:文档正文以 Markdown 文件形式存放在磁盘(`storage/projects/<uuid>/…`),数据库只保存权限与元数据,备份/迁移只需拷目录。
|
||||
- 📝 **Markdown 编辑**:基于 ByteMD 的编辑器,支持 **编辑 / 分栏 / 预览** 三态切换,右侧悬浮目录(TOC)在纯编辑模式下同样可用;支持 GFM、代码高亮、Frontmatter、emoji、图片上传。
|
||||
- 🌲 **无限层级目录树**:文件/文件夹创建、重命名、拖拽移动、排序。
|
||||
- 👥 **团队协作**:项目成员与项目内角色(admin / editor / viewer),公开项目与「参与项目」列表。
|
||||
- 🔎 **双引擎检索**:本地全文检索(Whoosh + jieba 中文分词)+ 向量检索(ZVec / 远程 Embedding)。
|
||||
- 🤖 **AI 知识库问答**:基于项目文档的 RAG 对话,返回引用文件与命中片段,可中断、可回看思考过程与耗时。
|
||||
- 🧩 **模型配置中心**:在系统管理里维护 Chat / Embedding 模型(OpenAI 兼容接口、本地 sentence-transformers),运行时热切换。
|
||||
- 🔗 **分享与预览**:项目/单文件分享链接(可带访问密码)、Markdown / PDF / 图片预览,PDF 支持服务端导出。
|
||||
- 🔐 **RBAC**:用户 / 角色 / 菜单与按钮级权限点,侧边栏按授权动态生成。
|
||||
- 📄 **通知与审计**:站内通知中心、全量操作日志检索。
|
||||
- 🔄 **Git 双向同步**:项目可绑定 Git 仓库,按目录同步文档。
|
||||
- 🛰️ **MCP 接入**:为 MCP Bot 提供凭证与文档读写能力。
|
||||
- 🌗 **明暗双主题**:全站统一设计令牌(`styles/design-tokens.css` + antd 主题),跟随系统或手动切换。
|
||||
|
||||
---
|
||||
## 🏗️ 技术栈
|
||||
|
||||
## 🏗️ 技术架构
|
||||
### 后端
|
||||
| 组件 | 选型 |
|
||||
| --- | --- |
|
||||
| Web 框架 | FastAPI 0.109 + Uvicorn(全异步) |
|
||||
| ORM | SQLAlchemy 2.0(asyncio + aiomysql) |
|
||||
| 数据库 | MySQL 8.0(utf8mb4) |
|
||||
| 缓存/队列 | Redis 7 |
|
||||
| 认证 | JWT(python-jose)+ bcrypt 密码哈希 |
|
||||
| 检索 | Whoosh3 + jieba(全文)、ZVec + sentence-transformers / 远程 Embedding(向量) |
|
||||
| 文档处理 | markdown、weasyprint(PDF)、python-magic |
|
||||
| 集成 | OpenAI 兼容 LLM 接口、系统 `git` 命令(subprocess 调用)、MCP SDK |
|
||||
|
||||
### 后端技术栈
|
||||
- **框架**: FastAPI (Python 3.9+)
|
||||
- **ORM**: SQLAlchemy 2.0 (异步)
|
||||
- **数据库**: MySQL 5.7.5+
|
||||
- **缓存**: Redis
|
||||
- **认证**: JWT (PyJWT)
|
||||
- **文件**: aiofiles (异步 I/O)
|
||||
|
||||
### 前端技术栈
|
||||
- **框架**: React 18
|
||||
- **构建工具**: Vite
|
||||
- **UI 组件**: Ant Design 5
|
||||
- **路由**: React Router v6
|
||||
- **状态管理**: Zustand
|
||||
- **Markdown**: @uiw/react-md-editor
|
||||
- **样式**: Tailwind CSS
|
||||
|
||||
---
|
||||
### 前端
|
||||
| 组件 | 选型 |
|
||||
| --- | --- |
|
||||
| 框架 | React 18 + React Router v6 |
|
||||
| 构建 | Vite 5(`manualChunks` 分包、全部路由 lazy 加载) |
|
||||
| UI | Ant Design 5 + 自研设计令牌(无 Tailwind / postcss) |
|
||||
| 状态 | Zustand + Axios 统一封装 |
|
||||
| Markdown | ByteMD(`@bytemd/react` + gfm/highlight/frontmatter/breaks/gemoji)+ react-markdown 渲染 |
|
||||
| 其它 | react-pdf / pdfjs-dist、react-virtuoso、antd-img-crop |
|
||||
|
||||
## 📦 项目结构
|
||||
|
||||
```
|
||||
NEX Docus/
|
||||
├── backend/ # FastAPI 后端服务
|
||||
NexDocus/
|
||||
├── backend/ # FastAPI 后端
|
||||
│ ├── app/
|
||||
│ │ ├── api/v1/ # API 路由(v1)
|
||||
│ │ ├── core/ # 核心配置
|
||||
│ │ ├── models/ # 数据库模型
|
||||
│ │ ├── schemas/ # Pydantic Schemas
|
||||
│ │ ├── services/ # 业务逻辑服务
|
||||
│ │ └── middleware/ # 中间件
|
||||
│ ├── scripts/ # 初始化脚本
|
||||
│ └── main.py # 应用入口
|
||||
│ │ ├── api/v1/ # API 路由(认证/项目/文件/检索/对话/分享/系统…)
|
||||
│ │ ├── core/ # 配置、数据库、安全、依赖注入、幂等迁移
|
||||
│ │ ├── models/ # SQLAlchemy 模型(18 张表)
|
||||
│ │ ├── schemas/ # Pydantic Schema
|
||||
│ │ ├── services/ # 业务逻辑(存储、检索、向量化、RAG、Git、导出…)
|
||||
│ │ └── mcp/ # MCP Streamable HTTP 接入(凭证鉴权 + 工具注册)
|
||||
│ ├── scripts/ # 数据库初始化脚本(随代码走,Docker 构建上下文需要)
|
||||
│ ├── tests/ # pytest 用例
|
||||
│ └── main.py # 应用入口
|
||||
│
|
||||
├── frontend/ # React 前端应用
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API 封装
|
||||
│ │ ├── components/ # 通用组件
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ ├── stores/ # 状态管理
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ └── package.json
|
||||
├── frontend/ # React 前端
|
||||
│ └── src/
|
||||
│ ├── api/ # 接口封装
|
||||
│ ├── components/ # 通用组件(Feedback 统一提示、MainLayout…)
|
||||
│ ├── data/ # 静态配置数据
|
||||
│ ├── pages/ # 页面(全部 lazy 加载)
|
||||
│ ├── stores/ # Zustand
|
||||
│ ├── styles/ # design-tokens.css 等全局样式
|
||||
│ ├── theme/ # antd 主题(明/暗)
|
||||
│ └── utils/
|
||||
│
|
||||
├── DATABASE.md # 数据库设计文档
|
||||
├── PROJECT.md # 技术方案文档
|
||||
├── QUICKSTART.md # 快速启动指南
|
||||
└── IMPLEMENTATION_PLAN.md # 实施计划
|
||||
├── scripts/ # 运维/开发脚本(start / stop / deploy)
|
||||
├── docs/ # 全部文档(见 docs/README.md)
|
||||
├── storage/ # 运行期文件存储(已 gitignore)
|
||||
├── backup/ # 数据库备份产物(已 gitignore)
|
||||
├── .run/ # 本地 pid / 日志(已 gitignore)
|
||||
├── docker-compose.yml # 容器编排
|
||||
└── .env.example # Docker 部署环境变量模板
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置要求
|
||||
### 环境要求
|
||||
- Python **3.10+**(推荐 3.12)
|
||||
- Node.js **18+**
|
||||
- MySQL **8.0**、Redis **7**(已有实例亦可,Docker 部署会自动拉起)
|
||||
- 启用 Git 同步时需要本机存在 `git` 可执行文件(后端镜像已内置)
|
||||
|
||||
- Python 3.9.6+
|
||||
- Node.js 16+
|
||||
- MySQL 5.7.5+
|
||||
- Redis (最新版)
|
||||
|
||||
### 1. 初始化数据库
|
||||
### 方式一:一键启动(本地开发,推荐)
|
||||
|
||||
```bash
|
||||
mysql -h10.100.51.51 -uroot -pUnis@321 < backend/scripts/init_database.sql
|
||||
./scripts/start.sh
|
||||
```
|
||||
|
||||
### 2. 启动后端
|
||||
首次运行会:创建 `backend/venv` → 生成 `backend/.env` 模板(**此时会停下**,请填好 MySQL/Redis 连接信息后重新执行)→ 安装前后端依赖 → 真实校验 MySQL/Redis 连通性 → 幂等初始化数据库 → 拉起后端与前端。
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
./scripts/start.sh --backend # 只启动后端
|
||||
./scripts/start.sh --frontend # 只启动前端
|
||||
./scripts/start.sh --install # 只准备环境,不启动服务
|
||||
./scripts/start.sh --init-db # 强制重跑数据库初始化
|
||||
./scripts/start.sh --port 8001 # 换端口(环境变量优先,不改 .env)
|
||||
./scripts/start.sh --daemon # 启动后立即返回(后台常驻)
|
||||
./scripts/stop.sh # 停止
|
||||
```
|
||||
|
||||
后端将运行在 http://localhost:8000
|
||||
启动完成后:前端 http://localhost:5173 · 后端 http://localhost:8000 · 接口文档 http://localhost:8000/docs
|
||||
|
||||
### 3. 启动前端
|
||||
### 方式二:Docker Compose(服务器部署)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
cp .env.example .env # 按注释修改密码/端口/存储路径
|
||||
./scripts/deploy.sh init # 生成配置、构建镜像、初始化数据库
|
||||
./scripts/deploy.sh start
|
||||
./scripts/deploy.sh status
|
||||
```
|
||||
|
||||
前端将运行在 http://localhost:5173
|
||||
细节见 [docs/deploy/README.md](docs/deploy/README.md)。
|
||||
|
||||
### 4. 登录使用
|
||||
### 默认账号
|
||||
|
||||
- **用户名**: `admin`
|
||||
- **密码**: `admin123`
|
||||
| 场景 | 用户名 | 密码 |
|
||||
| --- | --- | --- |
|
||||
| `scripts/start.sh` / `backend/scripts/init_db.py` | `admin` | `admin@123` |
|
||||
| Docker 部署(`.env.example` 默认值) | `admin` | `Admin@123456` |
|
||||
|
||||
---
|
||||
可用 `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL` / `ADMIN_NICKNAME` 覆盖;**首次登录后请立即修改密码**。
|
||||
|
||||
## 📖 核心功能
|
||||
## 📖 文档索引
|
||||
|
||||
### 用户认证
|
||||
- ✅ 用户注册/登录
|
||||
- ✅ JWT Token 认证
|
||||
- ✅ 密码加密存储
|
||||
- ✅ 权限角色管理
|
||||
| 文档 | 内容 |
|
||||
| --- | --- |
|
||||
| [docs/README.md](docs/README.md) | 文档地图(从这里开始) |
|
||||
| [docs/quickstart.md](docs/quickstart.md) | 开发环境快速上手、常见启动问题 |
|
||||
| [docs/deploy/README.md](docs/deploy/README.md) | Docker 部署、升级、备份与恢复 |
|
||||
| [docs/database.md](docs/database.md) | 数据库 18 张表结构与初始化链路 |
|
||||
| [docs/manual/user-guide.md](docs/manual/user-guide.md) | 面向使用者的功能手册 |
|
||||
| [docs/sdd/](docs/sdd/) | 规格驱动开发文档:愿景、架构、ADR、DV 规格、发布记录 |
|
||||
| [scripts/README.md](scripts/README.md) | 脚本清单与约定 |
|
||||
|
||||
### 项目管理
|
||||
- ✅ 创建/编辑/删除项目
|
||||
- ✅ 项目成员管理
|
||||
- ✅ 访问权限控制
|
||||
- ✅ 项目归档
|
||||
## 🧪 开发与质量
|
||||
|
||||
### 文档编辑
|
||||
- ✅ Markdown 实时预览
|
||||
- ✅ 无限层级目录
|
||||
- ✅ 文件/文件夹操作
|
||||
- ✅ 图片/附件上传
|
||||
- ✅ 自动保存
|
||||
```bash
|
||||
# 前端:静态检查与构建
|
||||
cd frontend && npm run lint && npm run build
|
||||
|
||||
---
|
||||
# 后端:pytest(用例较少,见发布报告 OI 清单)
|
||||
cd backend && ./venv/bin/pip install -r requirements-dev.txt
|
||||
cd backend && ./venv/bin/python -m pytest
|
||||
```
|
||||
|
||||
## 🗂️ 数据库设计
|
||||
- 前端统一使用 `@/` 别名指向 `frontend/src`;新页面必须在 `App.jsx` 用 `lazy()` 注册。
|
||||
- 交互提示统一走 `components/Feedback` 的 `Toast`(`Toast.confirm` 替代 `Modal.confirm`)。
|
||||
- 颜色/圆角/间距一律取 `styles/design-tokens.css` 与 antd token,禁止硬编码色值。
|
||||
- 数据库结构变更:改 `app/models/`,并在 `app/core/migrations.py` 增加幂等补列逻辑;**不要**再往仓库里追加一次性 `.sql`。
|
||||
|
||||
### 核心表结构
|
||||
## 🔒 安全要点
|
||||
|
||||
- `users` - 用户表
|
||||
- `roles` - 角色表
|
||||
- `user_roles` - 用户角色关联
|
||||
- `system_menus` - 系统菜单
|
||||
- `role_menus` - 角色菜单授权
|
||||
- `projects` - 项目表
|
||||
- `project_members` - 项目成员
|
||||
- `document_meta` - 文档元数据(可选)
|
||||
- `operation_logs` - 操作日志
|
||||
|
||||
详细设计请查看 [DATABASE.md](DATABASE.md)
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全特性
|
||||
|
||||
- ✅ **密码加密**: bcrypt 加密存储
|
||||
- ✅ **JWT 认证**: 访问令牌机制
|
||||
- ✅ **路径安全**: 防止路径穿越攻击
|
||||
- ✅ **权限校验**: 基于角色的访问控制
|
||||
- ✅ **SQL 注入防护**: ORM 自动防护
|
||||
- ✅ **CORS 配置**: 跨域请求控制
|
||||
|
||||
---
|
||||
|
||||
## 📋 API 文档
|
||||
|
||||
启动后端后访问:
|
||||
|
||||
- **Swagger UI**: http://localhost:8000/docs
|
||||
- **ReDoc**: http://localhost:8000/redoc
|
||||
|
||||
### 主要接口
|
||||
|
||||
**认证**
|
||||
- `POST /api/v1/auth/register` - 注册
|
||||
- `POST /api/v1/auth/login` - 登录
|
||||
- `GET /api/v1/auth/me` - 获取当前用户
|
||||
|
||||
**项目**
|
||||
- `GET /api/v1/projects/` - 项目列表
|
||||
- `POST /api/v1/projects/` - 创建项目
|
||||
- `GET /api/v1/projects/{id}` - 项目详情
|
||||
- `PUT /api/v1/projects/{id}` - 更新项目
|
||||
- `DELETE /api/v1/projects/{id}` - 删除项目
|
||||
|
||||
**文件**
|
||||
- `GET /api/v1/files/{project_id}/tree` - 目录树
|
||||
- `GET /api/v1/files/{project_id}/file` - 文件内容
|
||||
- `POST /api/v1/files/{project_id}/file` - 保存文件
|
||||
- `POST /api/v1/files/{project_id}/upload` - 上传文件
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 开发指南
|
||||
|
||||
### 后端开发
|
||||
|
||||
1. 添加新的数据模型到 `backend/app/models/`
|
||||
2. 定义 Pydantic Schema 到 `backend/app/schemas/`
|
||||
3. 在 `backend/app/api/v1/` 创建路由
|
||||
4. 实现业务逻辑到 `backend/app/services/`
|
||||
|
||||
### 前端开发
|
||||
|
||||
1. 在 `frontend/src/api/` 封装 API 请求
|
||||
2. 在 `frontend/src/pages/` 创建页面组件
|
||||
3. 在 `frontend/src/App.jsx` 添加路由
|
||||
4. 使用 Zustand 管理全局状态
|
||||
|
||||
---
|
||||
|
||||
## 📝 文档
|
||||
|
||||
- [技术方案文档](PROJECT.md) - 完整的技术设计方案
|
||||
- [数据库设计](DATABASE.md) - 数据库表结构设计
|
||||
- [快速启动指南](QUICKSTART.md) - 5分钟快速上手
|
||||
- [实施计划](IMPLEMENTATION_PLAN.md) - 分阶段实施计划
|
||||
|
||||
---
|
||||
|
||||
## 🔄 版本历史
|
||||
|
||||
> ⚠️ 版本对齐:git 发布线为 v0.9.1 → v0.9.2 → v0.9.6 → v0.9.7 → v0.9.8 → v0.9.9(当前基线,提交 `416ef48`),仓库未打 tag。下列 v1.0.0 为早期文案占位,与 git 不符;详见 SDD `docs/sdd/releases/`。
|
||||
|
||||
### v1.0.0 (2023-12-20,遗留占位,见上方对齐说明)
|
||||
|
||||
- ✅ 完整的用户认证系统
|
||||
- ✅ 项目管理功能
|
||||
- ✅ Markdown 文档编辑
|
||||
- ✅ 文件系统管理
|
||||
- ✅ 权限控制体系
|
||||
- ✅ 团队协作功能
|
||||
|
||||
---
|
||||
- 密码 bcrypt 存储;JWT 过期时间由 `ACCESS_TOKEN_EXPIRE_MINUTES` 控制。
|
||||
- 文件路径全部经过规范化校验,拒绝路径穿越;上传大小与类型受限。
|
||||
- `DEBUG=True` 会打印全量 SQL,**生产必须关闭**。
|
||||
- `SECRET_KEY`、数据库/Redis 密码、Git Token 必须由环境注入,禁止提交到仓库;`backend/.env`、`.env` 已在 `.gitignore` 中。
|
||||
- 分享链接的访问密码当前为明文存储于 `share_links.access_pass`,见发布报告 P2 项。
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
Copyright © 2023 Mula.liu
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
感谢以下开源项目:
|
||||
|
||||
- [FastAPI](https://fastapi.tiangolo.com/)
|
||||
- [React](https://reactjs.org/)
|
||||
- [Ant Design](https://ant.design/)
|
||||
- [SQLAlchemy](https://www.sqlalchemy.org/)
|
||||
- [Vite](https://vitejs.dev/)
|
||||
Copyright © 2026 Mula.liu
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
193
README_DOCKER.md
193
README_DOCKER.md
|
|
@ -1,193 +0,0 @@
|
|||
# NEX Docus
|
||||
|
||||
<div align="center">
|
||||
|
||||
**现代化的文档管理系统**
|
||||
|
||||
[](LICENSE)
|
||||
[](DEPLOY.md)
|
||||
[](backend/)
|
||||
[](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**
|
||||
|
|
@ -1,158 +1,106 @@
|
|||
# NEX Docus Backend
|
||||
# NEX Docus 后端
|
||||
|
||||
NEX Docus 后端服务 - 基于 FastAPI 构建的高性能文档管理平台。
|
||||
FastAPI + SQLAlchemy 2.0(异步)+ MySQL + Redis 的文档管理后端,同时提供 RAG 知识库问答与 MCP 服务。
|
||||
|
||||
> **上手请先看 [`docs/quickstart.md`](../docs/quickstart.md)**(一键启动、环境变量、初始化、常见问题)。
|
||||
> 部署/运维见 [`docs/deploy/README.md`](../docs/deploy/README.md),表结构见 [`docs/database.md`](../docs/database.md)。
|
||||
> 本文件只描述**后端代码内部结构**与开发约定。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: FastAPI 0.109+
|
||||
- **数据库**: MySQL 5.7.5+ (通过 SQLAlchemy 2.0 异步 ORM)
|
||||
- **缓存**: Redis
|
||||
- **认证**: JWT (python-jose)
|
||||
- **密码加密**: bcrypt (passlib)
|
||||
- **文件处理**: aiofiles (异步文件 I/O)
|
||||
| 用途 | 选型 |
|
||||
| --- | --- |
|
||||
| Web 框架 | FastAPI + Uvicorn |
|
||||
| ORM | SQLAlchemy 2.0 异步(aiomysql),Alembic 迁移 |
|
||||
| 数据库 / 缓存 | MySQL 8(utf8mb4)/ Redis |
|
||||
| 认证 | JWT(python-jose)+ bcrypt(passlib) |
|
||||
| 全文检索 | Whoosh 3 + jieba 分词 |
|
||||
| 向量检索 | ZVec(远端 Embedding 或本地 Sentence-Transformers) |
|
||||
| 文件与导出 | aiofiles、自研 Markdown/PDF 导出服务 |
|
||||
| Git 同步 | 通过命令行调用 `git`(主机需自行安装) |
|
||||
| 测试 | pytest + pytest-asyncio |
|
||||
|
||||
## 项目结构
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ └── v1/ # API 路由(v1 版本)
|
||||
│ │ ├── auth.py # 用户认证
|
||||
│ │ ├── projects.py # 项目管理
|
||||
│ │ └── files.py # 文件系统
|
||||
│ ├── core/ # 核心配置
|
||||
│ │ ├── config.py # 应用配置
|
||||
│ │ ├── database.py # 数据库连接
|
||||
│ │ ├── security.py # 安全工具
|
||||
│ │ └── deps.py # 依赖注入
|
||||
│ ├── models/ # 数据库模型
|
||||
│ ├── schemas/ # Pydantic Schemas
|
||||
│ ├── services/ # 业务逻辑
|
||||
│ ├── middleware/ # 中间件
|
||||
│ └── utils/ # 工具函数
|
||||
├── scripts/ # 脚本文件
|
||||
│ ├── init_database.sql # 数据库初始化 SQL
|
||||
│ └── init_db.py # 数据库初始化 Python 脚本
|
||||
├── tests/ # 测试文件
|
||||
├── main.py # 应用入口
|
||||
├── requirements.txt # 依赖包
|
||||
└── .env # 环境配置
|
||||
|
||||
│ ├── api/v1/ # HTTP 路由(前缀 /api/v1),__init__.py 汇总注册
|
||||
│ ├── core/ # config / database / deps / security / enums / migrations / redis_client
|
||||
│ ├── mcp/ # MCP server 与请求上下文(挂载在 /mcp)
|
||||
│ ├── models/ # SQLAlchemy 模型(__init__.py 必须导入全部模型,见下方陷阱)
|
||||
│ ├── schemas/ # Pydantic Schema 与统一响应包装
|
||||
│ └── services/ # 业务逻辑:project/file/search/rag/zvec/git/vectorization/notification/log/storage/llm…
|
||||
├── scripts/ # init_db.py(建表+种子数据)、generate_password.py,详见 scripts/README.md
|
||||
├── tests/ # pytest 用例
|
||||
├── models/ # 本地向量模型目录(gitignore,需自行放置 HF 模型,如 m3e-small)
|
||||
├── main.py # 应用入口:/api/v1 路由、/mcp 挂载、/ 与 /health
|
||||
├── requirements.txt # 运行依赖
|
||||
├── requirements-dev.txt # 开发/测试依赖(含 pytest)
|
||||
├── pytest.ini # testpaths=tests, pythonpath=.
|
||||
└── .env # 本地配置(不入库;键名见下表)
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
## 路由一览
|
||||
|
||||
### 1. 安装依赖
|
||||
`main.py` 暴露:`GET /`(名称/版本/状态)、`GET /health`(`{"status":"healthy"}`)、`/docs`、`/openapi.json`、`/mcp`(MCP Streamable HTTP,需 `X-Bot-Id` / `X-Bot-Secret`)。
|
||||
|
||||
`app/api/v1/__init__.py` 注册的子路由前缀:
|
||||
|
||||
| 前缀 | 模块 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `/auth` | auth | 登录、当前用户、资料/密码/头像、MCP 凭证签发与轮换 |
|
||||
| `/projects` | projects | 项目 CRUD、成员、所有权转移、分享开关、Git pull/push 与目录选择 |
|
||||
| (同 `/projects/{id}/git-repos`) | git_repos | 仓库配置的增删改查与连通性测试(路由内部自带前缀) |
|
||||
| `/files` | files | 目录树、读写文件、文件操作、上传、导入导出、PDF 导出、文档与静态资源流式访问 |
|
||||
| `/preview` | preview | 项目预览 |
|
||||
| `/shares` | shares | 分享链接创建/校验/访问 |
|
||||
| `/search` | search | 全文 + 向量双引擎检索 |
|
||||
| `/chat` | chat | 会话管理、SSE 流式问答、引用与中断 |
|
||||
| `/llm-model-configs` | llm_model_configs | chat / embedding 模型配置与连通性测试 |
|
||||
| `/dashboard` | dashboard | 管理员统计 |
|
||||
| `/users` `/roles` `/role-permissions` `/menu` | users/roles/role_permissions/menu | 用户、角色、角色权限、菜单树 |
|
||||
| `/notifications` | notifications | 站内通知 |
|
||||
| `/logs` | logs | 系统日志查询 |
|
||||
|
||||
## 环境变量(`backend/.env`)
|
||||
|
||||
完整键表与说明见 [`docs/quickstart.md`](../docs/quickstart.md);此处只列必填项与常见坑:
|
||||
|
||||
- 必填:`DB_HOST` `DB_USER` `DB_PASSWORD` `DB_NAME`、`REDIS_HOST` `REDIS_PASSWORD`、`SECRET_KEY`。
|
||||
- 文件存储:`STORAGE_ROOT` / `PROJECTS_PATH` / `USERS_PATH` / `TEMP_PATH`(**本地开发用这组绝对路径**;Docker 部署用根 `.env` 的 `STORAGE_PATH`,两套体系不同,别混用)。
|
||||
- `DEBUG=True` 时 SQLAlchemy 会打印全量 SQL,**生产必须 `false`**。
|
||||
- `DB_PASSWORD` 含特殊字符无需手动转义(连接串构造时已 `quote_plus`)。
|
||||
|
||||
> ⚠️ **不要在代码、文档或提交里写真实环境的账号密码。** 需要示例时用占位值(如 `password_change_me`)。
|
||||
|
||||
## 开发命令
|
||||
|
||||
```bash
|
||||
# 激活虚拟环境
|
||||
source venv/bin/activate # macOS/Linux
|
||||
# 或
|
||||
venv\Scripts\activate # Windows
|
||||
cd backend
|
||||
python3 -m venv venv
|
||||
./venv/bin/pip install -r requirements-dev.txt # 含运行依赖 + pytest
|
||||
cp .env.example .env # 若无,按 docs/quickstart.md 的键表创建
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
./venv/bin/python scripts/init_db.py # 建表 + 种子数据(幂等)
|
||||
./venv/bin/python scripts/generate_password.py # 生成 bcrypt 哈希
|
||||
./venv/bin/python -m pytest # 单元测试
|
||||
./venv/bin/uvicorn main:app --reload --port 8000 # 单独起服务(推荐用仓库根 ./scripts/start.sh)
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
## 必须知道的实现约定
|
||||
|
||||
编辑 `.env` 文件,配置数据库连接等信息。
|
||||
1. **新增模型要在 `app/models/__init__.py` 里导入。** 只建表逻辑而不导入,`Base.metadata` 就看不到该表,`init_db.py` 会**静默漏建表**,线上表现为 `Table ... doesn't exist`。
|
||||
2. **项目权限只走 `app/services/project_service.py`**(`require_project_read_access` / `require_project_write_access` / `normalize_project_role`)。不要在路由里手写角色判断:历史数据里 `project_members.role` 存在大写(`ADMIN`/`EDITOR`/`VIEWER`),直接 `== "admin"` 比较会静默失效。返回给前端的角色必须先 `normalize_project_role`。
|
||||
3. **异步 Session 配了 `expire_on_commit=False`**,但 `onupdate=func.now()` 的列(如 `updated_at`)在 UPDATE 提交后仍会被标记过期;提交后若还要序列化整个 ORM 对象,**必须 `await db.refresh(obj)`**,否则会在异步上下文触发同步 IO 抛 `MissingGreenlet`(HTTP 500)。
|
||||
4. **统一响应**用 `app/schemas/response.py` 的 `success_response()`;业务错误抛 `HTTPException` 并用 4xx 表达(不要用 500 表达"参数/状态不合法")。
|
||||
5. **SSE 接口**(`/chat`)要求网关关闭响应缓冲,见 `docs/deploy/README.md` 的 nginx 示例。
|
||||
6. **本地向量模型**放在 `backend/models/<模型名>/`(含 `config.json`、`1_Pooling/config.json`、`model.safetensors` 或 `pytorch_model.bin`),由 `LocalEmbeddingService` 扫描列出;该目录已 gitignore,不要提交。
|
||||
|
||||
### 3. 初始化数据库
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
# 方式一:使用 SQL 脚本(推荐)
|
||||
mysql -h10.100.51.51 -uroot -pUnis@321 < scripts/init_database.sql
|
||||
|
||||
# 方式二:使用 Python 脚本(仅创建表结构)
|
||||
python scripts/init_db.py
|
||||
cd backend && ./venv/bin/python -m pytest
|
||||
```
|
||||
|
||||
### 4. 启动服务
|
||||
|
||||
```bash
|
||||
# 开发模式(自动重载)
|
||||
python main.py
|
||||
|
||||
# 或使用 uvicorn
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
服务启动后,访问:
|
||||
- API 文档: http://localhost:8000/docs
|
||||
- 健康检查: http://localhost:8000/health
|
||||
|
||||
## API 接口
|
||||
|
||||
### 认证相关 (`/api/v1/auth`)
|
||||
|
||||
- `POST /register` - 用户注册
|
||||
- `POST /login` - 用户登录
|
||||
- `GET /me` - 获取当前用户信息
|
||||
- `POST /change-password` - 修改密码
|
||||
|
||||
### 项目管理 (`/api/v1/projects`)
|
||||
|
||||
- `GET /` - 获取我的项目列表
|
||||
- `POST /` - 创建新项目
|
||||
- `GET /{project_id}` - 获取项目详情
|
||||
- `PUT /{project_id}` - 更新项目信息
|
||||
- `DELETE /{project_id}` - 删除项目(归档)
|
||||
- `GET /{project_id}/members` - 获取项目成员
|
||||
- `POST /{project_id}/members` - 添加项目成员
|
||||
|
||||
### 文件系统 (`/api/v1/files`)
|
||||
|
||||
- `GET /{project_id}/tree` - 获取项目目录树
|
||||
- `GET /{project_id}/file?path=xxx` - 获取文件内容
|
||||
- `POST /{project_id}/file` - 保存文件内容
|
||||
- `POST /{project_id}/file/operate` - 文件操作(重命名/删除/创建)
|
||||
- `POST /{project_id}/upload` - 上传文件(图片/附件)
|
||||
- `GET /{project_id}/assets/{subfolder}/{filename}` - 获取资源文件
|
||||
|
||||
## 默认账号
|
||||
|
||||
初始化数据库后,会创建默认管理员账号:
|
||||
|
||||
- 用户名: `admin`
|
||||
- 密码: `admin123`
|
||||
|
||||
**⚠️ 生产环境请立即修改默认密码!**
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 代码风格
|
||||
|
||||
遵循 PEP 8 代码规范。
|
||||
|
||||
### 添加新的 API 端点
|
||||
|
||||
1. 在 `app/api/v1/` 下创建新的路由文件
|
||||
2. 在 `app/api/v1/__init__.py` 中注册路由
|
||||
3. 在 `app/schemas/` 中定义 Pydantic Schema
|
||||
4. 在 `app/services/` 中实现业务逻辑
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
使用 Alembic 进行数据库迁移:
|
||||
|
||||
```bash
|
||||
# 生成迁移脚本
|
||||
alembic revision --autogenerate -m "描述"
|
||||
|
||||
# 执行迁移
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
## 安全说明
|
||||
|
||||
1. **路径安全**: 所有文件系统操作都经过路径安全检查,防止路径穿越攻击
|
||||
2. **认证鉴权**: 使用 JWT Token 认证,所有需要登录的接口都受保护
|
||||
3. **权限控制**: 实现了项目级别的权限控制(owner/admin/editor/viewer)
|
||||
4. **密码加密**: 使用 bcrypt 加密存储密码
|
||||
5. **SQL 注入**: 使用 SQLAlchemy ORM,自动防止 SQL 注入
|
||||
|
||||
## 许可证
|
||||
|
||||
Copyright © 2023 Mula.liu
|
||||
覆盖范围(v1.0.0):项目权限与角色归一化、Git 服务命令构造、搜索服务、模型与向量配置、RAG 引用解析。**前端目前没有自动化测试**,界面回归需手工验证,重点清单见 [`docs/sdd/releases/v1.0.0.md`](../docs/sdd/releases/v1.0.0.md)。
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from app.models.project import Project, ProjectMember
|
|||
from app.models.log import OperationLog
|
||||
from app.core.enums import OperationType, ResourceType
|
||||
from app.schemas.response import success_response
|
||||
from app.services.project_service import normalize_project_role
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -167,7 +168,7 @@ async def get_personal_stats(
|
|||
"id": project.id,
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"role": member.role,
|
||||
"role": normalize_project_role(member.role),
|
||||
"joined_at": member.joined_at.isoformat() if member.joined_at else None,
|
||||
}
|
||||
for project, member in recent_shared_projects_rows
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ from app.core.deps import get_current_user, get_user_from_token_or_query
|
|||
from app.models.user import User
|
||||
from app.models.log import OperationLog
|
||||
from app.models.share import ShareLink
|
||||
from app.models.project import Project, ProjectMember
|
||||
from app.schemas.file import (
|
||||
FileTreeNode,
|
||||
FileSaveRequest,
|
||||
|
|
@ -40,45 +39,6 @@ from app.core.enums import OperationType
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
async def check_project_access(
|
||||
project_id: int,
|
||||
current_user: User,
|
||||
db: AsyncSession,
|
||||
require_write: bool = False
|
||||
):
|
||||
"""检查项目访问权限"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 检查是否是项目所有者
|
||||
if project.owner_id == current_user.id:
|
||||
return project
|
||||
|
||||
# 检查是否是项目成员
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
|
||||
if not member:
|
||||
if project.is_public == 1 and not require_write:
|
||||
return project
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
|
||||
# 如果需要写权限,检查成员角色
|
||||
if require_write and member.role == "viewer":
|
||||
raise HTTPException(status_code=403, detail="无写入权限")
|
||||
|
||||
return project
|
||||
|
||||
|
||||
def annotate_shared_files(tree: List[FileTreeNode], shared_paths: set[str]) -> None:
|
||||
"""为文件树节点补充分享状态"""
|
||||
for node in tree:
|
||||
|
|
@ -114,19 +74,8 @@ async def get_project_tree(
|
|||
}
|
||||
annotate_shared_files(tree, shared_paths)
|
||||
|
||||
# 获取当前用户角色
|
||||
user_role = "owner" # 默认是所有者
|
||||
if project.owner_id != current_user.id:
|
||||
# 查询成员角色
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if member:
|
||||
user_role = member.role
|
||||
# user_role 直接来自 require_project_read_access(已做历史大小写归一化),
|
||||
# 不要在这里再查一次成员表:重复查询曾导致前端拿到 VIEWER 这类历史值后判断失效。
|
||||
|
||||
return success_response(data={
|
||||
"tree": tree,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""
|
||||
通知管理 API (Redis版)
|
||||
"""
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List, Union
|
||||
from datetime import datetime
|
||||
|
|
@ -15,6 +17,8 @@ from app.schemas.notification import (
|
|||
from app.schemas.response import success_response
|
||||
from app.services.notification_service import notification_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
|
|
@ -58,7 +62,7 @@ async def get_notifications(
|
|||
}
|
||||
except Exception as e:
|
||||
# 降级处理,防止 500
|
||||
print(f"Error fetching notifications: {e}")
|
||||
logger.warning("读取通知列表失败,已降级为空列表: %s", e, exc_info=True)
|
||||
return {
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
|
|
@ -78,7 +82,7 @@ async def get_unread_count(
|
|||
count = await notification_service.get_unread_count(current_user.id)
|
||||
return success_response(data={"unread_count": count})
|
||||
except Exception as e:
|
||||
print(f"Error fetching unread count: {e}")
|
||||
logger.warning("读取未读通知数量失败,已降级为 0: %s", e, exc_info=True)
|
||||
return success_response(data={"unread_count": 0})
|
||||
|
||||
|
||||
|
|
@ -91,7 +95,7 @@ async def get_unread_by_project(
|
|||
counts = await notification_service.get_unread_count_by_project(current_user.id)
|
||||
return success_response(data={"unread_by_project": counts})
|
||||
except Exception as e:
|
||||
print(f"Error fetching unread by project: {e}")
|
||||
logger.warning("按项目统计未读通知失败,已降级为空: %s", e, exc_info=True)
|
||||
return success_response(data={"unread_by_project": {}})
|
||||
|
||||
|
||||
|
|
@ -105,7 +109,7 @@ async def mark_project_read(
|
|||
count = await notification_service.mark_project_read(current_user.id, project_id)
|
||||
return success_response(data={"marked": count}, message="项目通知已标记为已读")
|
||||
except Exception as e:
|
||||
print(f"Error marking project read: {e}")
|
||||
logger.warning("标记项目通知已读失败: %s", e, exc_info=True)
|
||||
return success_response(message="操作完成")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,12 @@ from app.services.storage import storage_service
|
|||
from app.services.log_service import log_service
|
||||
from app.services.git_service import git_service
|
||||
from app.services.notification_service import notification_service
|
||||
from app.services.project_service import normalize_project_role, require_project_roles
|
||||
from app.services.project_service import (
|
||||
normalize_project_role,
|
||||
require_project_read_access,
|
||||
require_project_roles,
|
||||
serialize_project,
|
||||
)
|
||||
from app.core.enums import OperationType, ResourceType
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -281,31 +286,20 @@ async def get_project(
|
|||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""获取项目详情"""
|
||||
# 查询项目
|
||||
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||
project = result.scalar_one_or_none()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
# 检查权限(项目所有者或成员可访问)
|
||||
if project.owner_id != current_user.id:
|
||||
member_result = await db.execute(
|
||||
select(ProjectMember).where(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member and project.is_public != 1:
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
# 权限与角色统一走 project_service,避免与其他接口出现两套判定
|
||||
project, user_role = await require_project_read_access(
|
||||
db, project_id, current_user, allow_public=True
|
||||
)
|
||||
|
||||
# 增加访问次数 (简单计数)
|
||||
project.visit_count += 1
|
||||
await db.commit()
|
||||
# updated_at 由数据库 onupdate 生成,commit 后该列处于过期状态,
|
||||
# 必须用异步 refresh 取回,否则序列化时会在异步上下文里触发同步 IO(MissingGreenlet -> 500)
|
||||
await db.refresh(project)
|
||||
|
||||
project_data = ProjectResponse.from_orm(project)
|
||||
return success_response(data=project_data.dict())
|
||||
project_data = serialize_project(project, user_role=user_role)
|
||||
return success_response(data=project_data)
|
||||
|
||||
|
||||
@router.put("/{project_id}", response_model=dict)
|
||||
|
|
@ -690,7 +684,7 @@ async def update_project_member_role(
|
|||
if not target_member:
|
||||
raise HTTPException(status_code=404, detail="该用户不是项目成员")
|
||||
|
||||
old_role = target_member.role
|
||||
old_role = normalize_project_role(target_member.role)
|
||||
target_member.role = member_in.role
|
||||
await db.commit()
|
||||
await db.refresh(target_member)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from app.schemas.project import FileShareCreate
|
|||
from app.schemas.response import success_response
|
||||
from app.services.log_service import log_service
|
||||
from app.services.pdf_service import pdf_service
|
||||
from app.services.project_service import normalize_project_role
|
||||
from app.services.search_service import search_service
|
||||
from app.services.storage import storage_service
|
||||
|
||||
|
|
@ -65,7 +66,7 @@ async def ensure_project_member(project: Project, current_user: User, db: AsyncS
|
|||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail="无权访问该项目")
|
||||
return member.role
|
||||
return normalize_project_role(member.role)
|
||||
|
||||
|
||||
async def get_share_by_code_or_404(share_code: str, share_type: Optional[str], db: AsyncSession) -> ShareLink:
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class Settings(BaseSettings):
|
|||
|
||||
# 应用信息
|
||||
APP_NAME: str = "NEX Docus"
|
||||
APP_VERSION: str = "0.9.9"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = True
|
||||
|
||||
# 服务器配置
|
||||
|
|
@ -84,6 +84,32 @@ class Settings(BaseSettings):
|
|||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
def security_warnings(self) -> List[str]:
|
||||
"""启动自检:返回仍在使用的占位/默认敏感配置说明(不阻断启动,仅告警)。"""
|
||||
warnings: List[str] = []
|
||||
if self.SECRET_KEY in INSECURE_SECRET_KEYS or len(self.SECRET_KEY) < 16:
|
||||
warnings.append(
|
||||
"SECRET_KEY 仍是占位值或过短,任何人都可伪造 JWT。"
|
||||
"请在 .env 中设置为随机值:openssl rand -hex 32"
|
||||
)
|
||||
if self.DEFAULT_USER_PASSWORD in DEFAULT_INSECURE_PASSWORDS:
|
||||
warnings.append(
|
||||
"DEFAULT_USER_PASSWORD 仍是仓库内置默认值,管理员新建的账号可被猜到。"
|
||||
"请在部署配置中改为随机初始密码"
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
# 仓库/模板里出现过的占位值:命中即视为未修改
|
||||
INSECURE_SECRET_KEYS = {
|
||||
"your-secret-key-change-me-in-production",
|
||||
"your-secret-key-change-me-in-production-use-openssl-rand-hex-32",
|
||||
"change_me",
|
||||
"change-me",
|
||||
"secret",
|
||||
}
|
||||
DEFAULT_INSECURE_PASSWORDS = {"User@123", "User@123456"}
|
||||
|
||||
|
||||
# 创建配置实例
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ from app.models.menu import SystemMenu, RoleMenu
|
|||
from app.models.project import Project, ProjectMember, ProjectMemberRole
|
||||
from app.models.document import DocumentMeta
|
||||
from app.models.document_vector import DocumentVector
|
||||
from app.models.git_repo import ProjectGitRepo
|
||||
from app.models.notification import Notification
|
||||
from app.models.share import ShareLink
|
||||
from app.models.log import OperationLog
|
||||
from app.models.mcp_bot import MCPBot
|
||||
|
|
@ -27,6 +29,8 @@ __all__ = [
|
|||
"ProjectMemberRole",
|
||||
"DocumentMeta",
|
||||
"DocumentVector",
|
||||
"ProjectGitRepo",
|
||||
"Notification",
|
||||
"ShareLink",
|
||||
"OperationLog",
|
||||
"MCPBot",
|
||||
|
|
|
|||
|
|
@ -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)} 维向量",
|
||||
}
|
||||
|
||||
|
|
@ -3,6 +3,7 @@ FastAPI 主应用
|
|||
"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from app.core.config import settings
|
||||
from app.core.redis_client import init_redis, close_redis
|
||||
|
|
@ -19,9 +20,15 @@ except RuntimeError:
|
|||
mcp_session_manager = None
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
# 敏感配置自检:命中占位值时告警(不阻断启动,避免影响存量部署)
|
||||
for warning in settings.security_warnings():
|
||||
logger.warning("[安全自检] %s", warning)
|
||||
# 启动时补齐数据库新增列(幂等),避免存量库缺少新字段
|
||||
await migrate_schema()
|
||||
# 启动时初始化 Redis
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
[pytest]
|
||||
testpaths = tests
|
||||
pythonpath = .
|
||||
addopts = -q
|
||||
|
|
@ -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
|
||||
|
|
@ -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 整理中删除,
|
||||
> 它们的语义已完全被上述幂等机制覆盖。
|
||||
|
|
@ -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访问令牌/密码';
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
-- 为项目Git仓库表增加“同步目录”字段(空=整个仓库)
|
||||
ALTER TABLE project_git_repos
|
||||
ADD COLUMN sync_path VARCHAR(255) DEFAULT '' COMMENT '同步目录(空=整个仓库)' AFTER is_default;
|
||||
|
|
@ -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())
|
||||
|
|
@ -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;
|
||||
|
|
@ -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())
|
||||
|
|
@ -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='知识库对话消息表';
|
||||
|
|
@ -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='文档向量表';
|
||||
|
|
@ -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仓库表';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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()
|
||||
|
|
@ -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='项目向量化任务表';
|
||||
|
|
@ -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='分享链接表';
|
||||
|
|
@ -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())
|
||||
|
|
@ -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;
|
||||
|
|
@ -144,7 +144,7 @@ async def init_menus():
|
|||
menu_name="知识库空间",
|
||||
menu_code="knowledge",
|
||||
menu_type=0,
|
||||
path="/knowledge",
|
||||
path="/chat",
|
||||
icon="BookOutlined",
|
||||
sort_order=4,
|
||||
visible=1,
|
||||
|
|
@ -220,7 +220,7 @@ async def init_menus():
|
|||
menu_name="我的知识库",
|
||||
menu_code="knowledge:my",
|
||||
menu_type=1,
|
||||
path="/knowledge",
|
||||
path="/chat",
|
||||
component="MyKnowledge",
|
||||
icon="CommentOutlined",
|
||||
sort_order=1,
|
||||
|
|
@ -348,7 +348,7 @@ async def init_admin_user():
|
|||
# 从环境变量获取管理员信息
|
||||
admin_username = os.getenv("ADMIN_USERNAME", "admin")
|
||||
admin_password = os.getenv("ADMIN_PASSWORD", "admin@123")
|
||||
admin_email = os.getenv("ADMIN_EMAIL", "admin@unisspace.com")
|
||||
admin_email = os.getenv("ADMIN_EMAIL", "admin@example.com")
|
||||
admin_nickname = os.getenv("ADMIN_NICKNAME", "系统管理员")
|
||||
|
||||
async with async_session() as session:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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`);
|
||||
|
|
@ -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`;
|
||||
|
|
@ -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`;
|
||||
|
|
@ -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())
|
||||
|
|
@ -5,6 +5,7 @@ from fastapi import HTTPException
|
|||
|
||||
from app.services.project_service import (
|
||||
normalize_project_role,
|
||||
require_project_read_access,
|
||||
require_project_roles,
|
||||
require_project_write_access,
|
||||
)
|
||||
|
|
@ -69,6 +70,28 @@ class ProjectPermissionsTest(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
self.assertEqual(context.exception.status_code, 403)
|
||||
|
||||
async def test_write_access_rejects_legacy_uppercase_viewer(self):
|
||||
"""历史数据里 role 可能是大写 VIEWER,写权限校验必须先归一化再判断。"""
|
||||
db = _RecordingDB([
|
||||
self.project,
|
||||
SimpleNamespace(role="VIEWER"),
|
||||
])
|
||||
|
||||
with self.assertRaises(HTTPException) as context:
|
||||
await require_project_write_access(db, 25, self.current_user)
|
||||
|
||||
self.assertEqual(context.exception.status_code, 403)
|
||||
|
||||
async def test_read_access_returns_normalized_role(self):
|
||||
db = _RecordingDB([
|
||||
self.project,
|
||||
SimpleNamespace(role="VIEWER"),
|
||||
])
|
||||
|
||||
project, role = await require_project_read_access(db, 25, self.current_user)
|
||||
|
||||
self.assertEqual(role, "viewer")
|
||||
|
||||
async def test_admin_only_permission_rejects_editor(self):
|
||||
db = _RecordingDB([
|
||||
self.project,
|
||||
|
|
|
|||
|
|
@ -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() == []
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
# MySQL 数据库
|
||||
mysql:
|
||||
|
|
@ -74,6 +72,7 @@ services:
|
|||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-Admin@123456}
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
|
||||
- ADMIN_NICKNAME=${ADMIN_NICKNAME:-系统管理员}
|
||||
- DEFAULT_USER_PASSWORD=${DEFAULT_USER_PASSWORD:-User@123456}
|
||||
- TZ=Asia/Shanghai
|
||||
volumes:
|
||||
- ${STORAGE_PATH:-./storage}:/data/nex_docus_store
|
||||
|
|
@ -96,8 +95,6 @@ services:
|
|||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VITE_API_BASE_URL=${VITE_API_BASE_URL:-http://localhost:8000}
|
||||
container_name: nex-docus-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -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` 是其可读镜像;两者冲突时以模型为准并回头修文档。
|
||||
|
|
@ -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. 归档文档中的外链可能已失效,反向链接(现行文档指向归档)应尽量避免。
|
||||
|
|
@ -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` 相关描述 |
|
||||
|
|
@ -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`。
|
||||
|
|
@ -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` 放大,避免答案被截断。
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
# 新文件
|
||||
|
||||
这是使用手册,我进一步修改了内容。测试和git仓库的同步。
|
||||
这次是用chatgpt修改的。
|
||||
|
|
@ -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:菜单由权限控制,见「系统管理 → 权限管理」中该角色的菜单授权。
|
||||
|
|
@ -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` |
|
||||
|
|
@ -28,8 +28,9 @@ docs/sdd/
|
|||
│ └── git.md # 项目 Git 仓库集成
|
||||
├── releases/
|
||||
│ ├── README.md # 公开版本与资产索引
|
||||
│ ├── v0.9.6.md # v0.9.6 历史升级记录(整合自 docs/)
|
||||
│ └── v0.9.9.md # 当前基线发布(对齐 git)
|
||||
│ ├── v1.0.0.md # 当前发布(首个正式版本,含验证边界与上线清单)
|
||||
│ ├── v0.9.9.md # 历史发布记录
|
||||
│ └── v0.9.6.md # 历史升级记录(整合自 docs/)
|
||||
└── specs/
|
||||
├── README.md # 功能规格索引
|
||||
├── _template/ # 新规格模板
|
||||
|
|
@ -38,11 +39,12 @@ docs/sdd/
|
|||
|
||||
## 当前状态(Status)
|
||||
|
||||
- **SDD 文档状态**:基线(Baseline)· 对齐 git 当前版本 v0.9.9
|
||||
- **适用代码基线**:当前仓库(backend + frontend + docker-compose 部署)
|
||||
- **SDD 文档状态**:v1.0.0 发布评审(Release Candidate)
|
||||
- **适用代码基线**:`ba80d28 fix project role permission`(main)+ 发布前整改(未提交)
|
||||
- **规格覆盖**:11 个功能单元(DV-0001 ~ DV-0011);价值主张 PO-1~5;架构决策 ADR-0001~0008
|
||||
- **已知整改项**:见 governance「开放问题」与各规格 tasks.md 中的“待整改/待决”标记(如敏感日志脱敏 TS-12、Compose v2 官方化 TS-13)
|
||||
- **版本基线**:以 git 为准(当前 v0.9.9;见 releases/README.md 与 v0.9.9.md 版本对齐说明;代码字段 1.0.0 为遗留占位)
|
||||
- **验证边界**:已复验项(pytest 40 passed / eslint 0 error / vite build / 编辑器与权限的浏览器实测)与**未验证项**(Docker 端到端、MCP 实连、Git 真实远端同步)统一登记在 [releases/v1.0.0.md](releases/v1.0.0.md)
|
||||
- **已知整改项**:以 [releases/v1.0.0.md 的「已知问题」表](releases/v1.0.0.md#已知问题开放项)为总表(含 P0 运维阻塞:Redis RDB 失败);细节见 governance「开放问题」与各规格 tasks.md
|
||||
- **版本基线**:**v1.0.0**(`APP_VERSION` 与 `package.json` 已对齐;**git tag `v1.0.0` 待创建**,属上线动作,见 releases/README.md)
|
||||
|
||||
## 如何使用本中心(工作流)
|
||||
|
||||
|
|
@ -71,9 +73,9 @@ docs/sdd/
|
|||
## 与外部文档的关系
|
||||
|
||||
SDD 中心**汇总并指向**仓库既有的详细材料,而非重复复制全部内容:
|
||||
- 数据库细节 → 仓库根 `DATABASE.md`(SDD 只保留 ER 概览与规格化的链路表)
|
||||
- 部署运维 → `DEPLOY.md`、`README_DOCKER.md`、`CHANGELOG_DEPLOY.md`(及已并入的 docs/DOCKER_DOCS_SETUP 历史,见 archive.md)
|
||||
- 历史变更 → `docs/UPGRADE_v0.9.6.md`、`docs/MIGRATION.md`(已整合/弃置,见 archive.md)
|
||||
- 数据库细节 → [`docs/database.md`](../database.md)(SDD 只保留 ER 概览与规格化的链路表)
|
||||
- 部署运维 → [`docs/deploy/README.md`](../deploy/README.md)、[配置变更日志](../deploy/changelog.md)(原 `DEPLOY.md`/`README_DOCKER.md`/`CHANGELOG_DEPLOY.md` 已合并去重)
|
||||
- 发布与已知问题 → [releases/v1.0.0.md](releases/v1.0.0.md);历史变更 → [releases/v0.9.6.md](releases/v0.9.6.md)(原 `docs/UPGRADE_v0.9.6.md`);`docs/MIGRATION.md` 已弃置,见 [archive.md](archive.md)
|
||||
- 结构规范 → `architecture/standards/`(原 docs/code-structure-* 已迁入)
|
||||
|
||||
> 本中心是入口与追踪层;具体逐表 DDL、逐配置项说明等细节仍以被指向的源文档为准。
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
| --- | --- | --- | --- |
|
||||
| OP-1 | Alembic vs 幂等 ALTER | 采用幂等 ALTER(`migrations.py`) | 评估引入 Alembic 以支撑大规模结构变更 |
|
||||
| OP-2 | 敏感日志 | `security.py`/`deps.py` 记录 SECRET_KEY 前缀/JWT 载荷 | 脱敏,见 OI-1 |
|
||||
| OP-3 | Compose v2 官方化 | deploy.sh 探测 v2 但根文档仍提及 v1 | 统一 v2 路径,见 OI-2 |
|
||||
| OP-3 | Compose v2 官方化 | 文档已统一 v2 与 `./scripts/deploy.sh` 路径,待一次真实环境端到端复验 | 见 OI-2、[v1.0.0 验证边界](../releases/v1.0.0.md) |
|
||||
| OP-4 | 测试覆盖 | 仅 3 个后端单测 | 增加集成/E2E,见 OI-3 |
|
||||
| OP-5 | 实时协同编辑 | 明确不在此版本做(vision 边界) | 另立项评估 OT/CRDT |
|
||||
| OP-6 | 多实例/高可用 | 单机部署,无状态服务仅后端(Redis/DB 独立) | 若需要再评估 |
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
仅支持 **Docker Compose v2**(`docker compose` 子命令)部署;弃用 v1。`deploy.sh` 中 `get_compose_cmd` 优先探测 v2,回退 v1 仅作兼容提示。
|
||||
|
||||
## 后果
|
||||
- 升级路径:安装 `docker-compose-plugin`(v2)并卸载 v1 后即可用 `./deploy.sh upgrade` 或 `docker compose up -d --build`。
|
||||
- 升级路径:安装 `docker-compose-plugin`(v2)并卸载 v1 后即可用 `./scripts/deploy.sh upgrade` 或 `docker compose up -d --build`。
|
||||
- `docker-compose.yml` 顶部 `version:` 仅产生弃用警告,不影响功能。
|
||||
|
||||
## 关联
|
||||
NFR-6, OI-2;部署细节见 `DEPLOY.md`、`README_DOCKER.md`。
|
||||
NFR-6, OI-2;部署细节见 [`docs/deploy/README.md`](../../../deploy/README.md)。
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 阶段性路线图(Roadmap)
|
||||
|
||||
> 基于仓库现状(IMPLEMENTATION_PLAN.md 三阶段已基本完成)与 SDD 梳理结果,重新组织为阶段化路线图。状态为 SDD 核对后的推断。
|
||||
> 基于仓库现状(原 IMPLEMENTATION_PLAN.md 三阶段已基本完成,现已归档到 [archive/IMPLEMENTATION_PLAN.md](../../archive/IMPLEMENTATION_PLAN.md))与 SDD 梳理结果,重新组织为阶段化路线图。状态为 SDD 核对后的推断。
|
||||
|
||||
## 阶段一:MVP 文档平台(已完成)
|
||||
- 认证与会话(DV-0001)
|
||||
|
|
|
|||
|
|
@ -6,20 +6,22 @@
|
|||
- 版本号语义化(MAJOR.MINOR.PATCH)。
|
||||
- 发布前必须:对应 DV verification.md 证据通过;ADR 无未决的重大分歧。
|
||||
- 发布文件中须给出「验证边界」:哪些 DV 被覆盖、如何复验、已知限制。
|
||||
- 部署相关变更同步到 `DEPLOY.md` / `CHANGELOG_DEPLOY.md`。
|
||||
- **版本以 git 为准**:发布必须对应一个 git 版本标记(提交信息中的 vX.Y.Z 或 tag);代码内版本字段应与 git 标记一致。
|
||||
- 部署/配置相关变更同步到 [`docs/deploy/README.md`](../../deploy/README.md) 与 [配置变更日志](../../deploy/changelog.md)。
|
||||
- **版本以 git 为准**:发布必须对应一个 git 版本标记(优先使用 tag;历史提交以提交信息中的 vX.Y.Z 为准);代码内版本字段应与 git 标记一致。
|
||||
|
||||
## 版本列表(对齐 git)
|
||||
| 版本 | 文件 | 对应 git 标记 | 主要规格覆盖 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| v0.9.9 | [v0.9.9.md](v0.9.9.md) | `416ef48 v0.9.9`(当前基线,HEAD `4ef3c92`) | 阶段一~三全部 DV | 当前基线 |
|
||||
| **v1.0.0** | [v1.0.0.md](v1.0.0.md) | 基线 `ba80d28`,**待打 tag `v1.0.0`** | DV-0001 ~ DV-0011 全量(首个正式版本) | 待发布 |
|
||||
| v0.9.9 | [v0.9.9.md](v0.9.9.md) | `416ef48 v0.9.9` / `f92fff6 基线版本` / `eea3b96 v0.9.9-SP1` | 阶段一~三全部 DV | 历史发布记录 |
|
||||
| v0.9.6 | [v0.9.6.md](v0.9.6.md) | `c005964 v0.9.6` | MCP 内集成/凭证管理/Python3.12/storage 持久化(历史) | 历史发布记录 |
|
||||
|
||||
> git 发布线:v0.9.1 → v0.9.2 → v0.9.6 → v0.9.7 → v0.9.8 → **v0.9.9**。仓库**未打 git tag**;上表以提交信息中的版本标记为准。
|
||||
> git 发布线:v0.9.1 → v0.9.2 → v0.9.6 → v0.9.7 → v0.9.8 → v0.9.9(含 `-SP1`)→ **v1.0.0(待打 tag)**。
|
||||
> 仓库历史**未使用 git tag**,上表在 v1.0.0 之前以提交信息中的版本标记为准;自 v1.0.0 起发布**必须打 tag**。
|
||||
>
|
||||
> v0.9.6 为**历史发布记录**(整合自原 `docs/UPGRADE_v0.9.6.md`);v0.9.9 为**当前基线**。
|
||||
> v1.0.0 是首个正式大版本:代码内版本字段(`APP_VERSION`、`package.json version`)已统一为 1.0.0,此前的「遗留占位」说明随本发布失效。
|
||||
>
|
||||
> ⚠️ 代码内版本字段(`APP_VERSION`、`package.json version`)与部分旧文档标注为 1.0.0,与 git 标记不符,属遗留占位,见 v0.9.9.md「版本对齐说明」。
|
||||
> v0.9.6 / v0.9.9 为**历史发布记录**(v0.9.6 整合自原 `docs/UPGRADE_v0.9.6.md`);其中的「版本对齐说明」「已知差异」保留原文,仅用于追溯。
|
||||
|
||||
## 历史部署变更参考(非发布、仅供追踪)
|
||||
- `CHANGELOG_DEPLOY.md`:v1.0.1 前端端口 80→8080、storage 目录映射(STORAGE_PATH)等(该记录与 git 发布线冲突,已标注意外,见 archive/版本对齐)。
|
||||
## 部署/配置类变更
|
||||
不占用发布号的运维与配置变更,记录在 [docs/deploy/changelog.md](../../deploy/changelog.md)(原根目录 `CHANGELOG_DEPLOY.md`,已迁入 `docs/`)。
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# Release v0.9.9(当前基线)
|
||||
# Release v0.9.9(历史记录)
|
||||
|
||||
> 基于 `git log` 版本标记对应的最新发布提交(`416ef48 v0.9.9`)与当前 HEAD(`4ef3c92 优化了显示`,位于 v0.9.9 之后)。仓库未打 git tag,此记录以提交信息中的版本标记为准。
|
||||
> **本文为历史发布记录**:v0.9.9 线已由 [v1.0.0](v1.0.0.md) 接替,文中「当前基线」「HEAD」等措辞为撰写当时(`4ef3c92`)的状态,保留原文以供追溯。
|
||||
>
|
||||
> 基于 `git log` 版本标记对应的最新发布提交(`416ef48 v0.9.9`)与当时的 HEAD(`4ef3c92 优化了显示`)。仓库未打 git tag,此记录以提交信息中的版本标记为准;v0.9.9 线后续还有 `f92fff6 v0.9.9 基线版本` 与 `d6ebd62`/`eea3b96 v0.9.9-SP1`。
|
||||
|
||||
## 元数据
|
||||
- **版本**:v0.9.9(当前基线)
|
||||
|
|
@ -19,7 +21,7 @@
|
|||
## 验证边界
|
||||
- 自动化测试:`backend/tests/test_chat_citations.py`、`test_model_and_vector_configuration.py`、`test_search_service.py`。
|
||||
- 运行验证:`/health` 返回 healthy;Swagger `/docs` 可用。
|
||||
- 部署验证:Compose v2 `docker compose up -d --build`;`./deploy.sh upgrade`。
|
||||
- 部署验证:Compose v2 `docker compose up -d --build`;`./scripts/deploy.sh upgrade`(脚本自 v1.0.0 起位于 `scripts/`)。
|
||||
- 已知限制:单机部署;无实时协同编辑;测试覆盖待增强(OI-3)。
|
||||
|
||||
## 已知问题
|
||||
|
|
@ -27,9 +29,9 @@
|
|||
| --- | --- | --- |
|
||||
| OI-1 | 敏感日志脱敏 | 待整改 |
|
||||
| OI-2 | Compose v2 官方化 | 待整改 |
|
||||
| OI-4 | 版本字段 1.0.0 与 git 标记不符,需统一 | 待整改(见本文档「版本对齐」说明) |
|
||||
| OI-4 | 版本字段 1.0.0 与 git 标记不符,需统一 | **已关闭**(v1.0.0 立项并显式建 tag,见 [v1.0.0.md](v1.0.0.md)) |
|
||||
|
||||
## 版本对齐说明
|
||||
- git 发布线:v0.9.1 → v0.9.2 → v0.9.6 → v0.9.7 → v0.9.8 → v0.9.9(`416ef48`)。
|
||||
- **无 v1.0.0** 的 git 版本;代码 `APP_VERSION=1.0.0`、`package.json version=1.0.0` 与文档中的 v1.0.0/v1.0.1 均为遗留占位,与 git 不符。
|
||||
- 建议:下次打 tag 时以 v0.9.9 为基线;若计划升级 v1.0.0 则应显式建 tag 并同步代码字段。
|
||||
- git 发布线:v0.9.1 → v0.9.2 → v0.9.6 → v0.9.7 → v0.9.8 → v0.9.9(`416ef48`,含 `-SP1`)。
|
||||
- 撰写本文时**尚无 v1.0.0** 的 git 版本,代码 `APP_VERSION=1.0.0`、`package.json version=1.0.0` 与文档中的 v1.0.0/v1.0.1 均为遗留占位。
|
||||
- **后续处置(已完成)**:v1.0.0 已作为首个正式版本立项,代码字段保留 1.0.0 并通过显式建 tag 对齐,见 [v1.0.0.md](v1.0.0.md)。OI-4 在本发布关闭。
|
||||
|
|
|
|||
|
|
@ -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`(或确认主机防火墙已拦截)。
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
涉及 `auth.py`(API)、`security.py`(JWT/bcrypt)、`deps.py`(依赖注入)、`redis_client.py`(TokenCache)。
|
||||
|
||||
## 数据模型
|
||||
- `users`(id/username/password_hash/…)——细节见 DATABASE.md。
|
||||
- `users`(id/username/password_hash/…)——细节见 [`docs/database.md`](../../../database.md)。
|
||||
|
||||
## 接口设计
|
||||
- POST /api/v1/auth/{register,login,logout}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
- `projects`(storage_key/owner_id/status/access_pass…)
|
||||
- `project_members`(role: admin/editor/viewer)
|
||||
- `document_meta`、`document_vector`
|
||||
- 细节见 DATABASE.md。
|
||||
- 细节见 [`docs/database.md`](../../../database.md)。
|
||||
|
||||
## 存储结构
|
||||
`STORAGE_ROOT/projects/<storage_key>/` + `_assets/images|files` + 默认 README.md。
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
## 数据模型
|
||||
- `roles`、`user_roles`、`system_menus`、`role_menus`。
|
||||
- 细节见 DATABASE.md。
|
||||
- 细节见 [`docs/database.md`](../../../database.md)。
|
||||
|
||||
## 接口设计
|
||||
- /api/v1/roles/*、/api/v1/role-permissions/*
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
- 代码/模块组织。
|
||||
|
||||
## 数据模型
|
||||
- 涉及表/字段(指向 DATABASE.md 细节)。
|
||||
- 涉及表/字段(指向 [`docs/database.md`](../../../database.md) 细节)。
|
||||
|
||||
## 接口设计
|
||||
- API 端点 / 交互。
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"name": "nex-docus-frontend",
|
||||
"private": true,
|
||||
"version": "0.9.9",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
|
||||
"lint": "eslint . --ext js,jsx",
|
||||
"lint:strict": "eslint . --ext js,jsx --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
|
|
@ -42,16 +43,15 @@
|
|||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-legacy": "^5.4.3",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"terser": "^5.46.0",
|
||||
"vite": "^5.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,38 @@
|
|||
import { useEffect } from 'react'
|
||||
import { lazy, Suspense, useEffect } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate, useParams, Outlet } from 'react-router-dom'
|
||||
import { ConfigProvider, theme } from 'antd'
|
||||
import { App as AntdApp, ConfigProvider, theme } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import useThemeStore from '@/stores/themeStore'
|
||||
import FeedbackBridge from '@/components/Feedback/FeedbackBridge'
|
||||
import PageLoading from '@/components/PageLoading/PageLoading'
|
||||
import { getAntdTheme } from '@/theme/antdTheme'
|
||||
import Login from '@/pages/Login/Login'
|
||||
import ProjectList from '@/pages/ProjectList/ProjectList'
|
||||
import DocumentPage from '@/pages/Document/DocumentPage'
|
||||
import DocumentEditor from '@/pages/Document/DocumentEditor'
|
||||
import Dashboard from '@/pages/Dashboard'
|
||||
import Desktop from '@/pages/Desktop'
|
||||
import Constructing from '@/pages/Constructing'
|
||||
import ProjectSharePage from '@/pages/Preview/ProjectSharePage'
|
||||
import FileSharePage from '@/pages/Preview/FileSharePage'
|
||||
import ProfilePage from '@/pages/Profile/ProfilePage'
|
||||
import Permissions from '@/pages/System/Permissions'
|
||||
import Users from '@/pages/System/Users'
|
||||
import Roles from '@/pages/System/Roles'
|
||||
import ModelConfigs from '@/pages/System/ModelConfigs'
|
||||
import SystemLogs from '@/pages/SystemLogs/SystemLogs'
|
||||
import Chat from '@/pages/Chat/Chat'
|
||||
import NotificationList from '@/pages/Notifications/NotificationList'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
import MainLayout from '@/components/MainLayout/MainLayout'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
import '@/App.css'
|
||||
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
// 路由级懒加载:首屏只加载登录页与布局,其余页面按需加载
|
||||
const ProjectList = lazy(() => import('@/pages/ProjectList/ProjectList'))
|
||||
const DocumentPage = lazy(() => import('@/pages/Document/DocumentPage'))
|
||||
const DocumentEditor = lazy(() => import('@/pages/Document/DocumentEditor'))
|
||||
const Dashboard = lazy(() => import('@/pages/Dashboard'))
|
||||
const Desktop = lazy(() => import('@/pages/Desktop'))
|
||||
const Constructing = lazy(() => import('@/pages/Constructing'))
|
||||
const ProjectSharePage = lazy(() => import('@/pages/Preview/ProjectSharePage'))
|
||||
const FileSharePage = lazy(() => import('@/pages/Preview/FileSharePage'))
|
||||
const ProfilePage = lazy(() => import('@/pages/Profile/ProfilePage'))
|
||||
const Permissions = lazy(() => import('@/pages/System/Permissions'))
|
||||
const Users = lazy(() => import('@/pages/System/Users'))
|
||||
const Roles = lazy(() => import('@/pages/System/Roles'))
|
||||
const ModelConfigs = lazy(() => import('@/pages/System/ModelConfigs'))
|
||||
const SystemLogs = lazy(() => import('@/pages/SystemLogs/SystemLogs'))
|
||||
const Chat = lazy(() => import('@/pages/Chat/Chat'))
|
||||
const NotificationList = lazy(() => import('@/pages/Notifications/NotificationList'))
|
||||
|
||||
// 重定向到文档页面的组件
|
||||
function RedirectToDocs() {
|
||||
const { projectId } = useParams()
|
||||
|
|
@ -47,11 +52,7 @@ function App() {
|
|||
const { isDarkMode } = useThemeStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (isDarkMode) {
|
||||
document.body.classList.add('dark')
|
||||
} else {
|
||||
document.body.classList.remove('dark')
|
||||
}
|
||||
document.body.classList.toggle('dark', isDarkMode)
|
||||
}, [isDarkMode])
|
||||
|
||||
return (
|
||||
|
|
@ -59,41 +60,52 @@ function App() {
|
|||
locale={zhCN}
|
||||
theme={{
|
||||
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
...getAntdTheme(isDarkMode),
|
||||
}}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/share/project/:shareCode" element={<ProjectSharePage />} />
|
||||
<Route path="/share/file/:shareCode" element={<FileSharePage />} />
|
||||
<AntdApp
|
||||
component={false}
|
||||
message={{ maxCount: 3 }}
|
||||
notification={{ placement: 'topRight', top: 24, duration: 3, maxCount: 3 }}
|
||||
>
|
||||
<FeedbackBridge>
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/share/project/:shareCode" element={<ProjectSharePage />} />
|
||||
<Route path="/share/file/:shareCode" element={<FileSharePage />} />
|
||||
|
||||
{/* 使用共享布局的路由 */}
|
||||
<Route element={<ProtectedRoute><LayoutWrapper /></ProtectedRoute>}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/desktop" element={<Desktop />} />
|
||||
<Route path="/projects" element={<Navigate to="/projects/my" replace />} />
|
||||
<Route path="/projects/my" element={<ProjectList type="my" />} />
|
||||
<Route path="/projects/share" element={<ProjectList type="share" />} />
|
||||
<Route path="/projects/:projectId/docs" element={<DocumentPage />} />
|
||||
<Route path="/projects/:projectId/editor" element={<DocumentEditor />} />
|
||||
<Route path="/projects/:projectId/*" element={<RedirectToDocs />} />
|
||||
<Route path="/constructing" element={<Constructing />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/notifications" element={<NotificationList />} />
|
||||
<Route path="/system/permissions" element={<Permissions />} />
|
||||
<Route path="/system/users" element={<Users />} />
|
||||
<Route path="/system/roles" element={<Roles />} />
|
||||
<Route path="/system/model-configs" element={<ModelConfigs />} />
|
||||
<Route path="/system/logs" element={<SystemLogs />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="/chat/new" element={<Chat />} />
|
||||
<Route path="/knowledge" element={<Navigate to="/chat" replace />} />
|
||||
<Route path="/knowledge/my" element={<Navigate to="/chat" replace />} />
|
||||
</Route>
|
||||
{/* 使用共享布局的路由 */}
|
||||
<Route element={<ProtectedRoute><LayoutWrapper /></ProtectedRoute>}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/desktop" element={<Desktop />} />
|
||||
<Route path="/projects" element={<Navigate to="/projects/my" replace />} />
|
||||
<Route path="/projects/my" element={<ProjectList type="my" />} />
|
||||
<Route path="/projects/share" element={<ProjectList type="share" />} />
|
||||
<Route path="/projects/:projectId/docs" element={<DocumentPage />} />
|
||||
<Route path="/projects/:projectId/editor" element={<DocumentEditor />} />
|
||||
<Route path="/projects/:projectId/*" element={<RedirectToDocs />} />
|
||||
<Route path="/constructing" element={<Constructing />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/notifications" element={<NotificationList />} />
|
||||
<Route path="/system/permissions" element={<Permissions />} />
|
||||
<Route path="/system/users" element={<Users />} />
|
||||
<Route path="/system/roles" element={<Roles />} />
|
||||
<Route path="/system/model-configs" element={<ModelConfigs />} />
|
||||
<Route path="/system/logs" element={<SystemLogs />} />
|
||||
<Route path="/chat" element={<Chat />} />
|
||||
<Route path="/chat/new" element={<Chat />} />
|
||||
<Route path="/knowledge" element={<Navigate to="/chat" replace />} />
|
||||
<Route path="/knowledge/my" element={<Navigate to="/chat" replace />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Navigate to="/projects" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Route path="/" element={<Navigate to="/projects" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</FeedbackBridge>
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export const sendChatMessageStream = async (sessionId, message, handlers = {}) =
|
|||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -13,6 +13,8 @@ import './LargeMarkdownEditor.css'
|
|||
|
||||
// 复用 ByteMD 底层的 codemirror-ssr,为超大文档提供带 Markdown 源码着色的纯文本编辑器。
|
||||
// 不引入新依赖,CodeMirror 5 的视口渲染让超大文档编辑保持流畅。
|
||||
// 注意:codemirror-ssr 导出的扩展注册函数恰好以 use 开头,它们不是 React Hook
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
function createCodeMirror() {
|
||||
const codemirror = factory()
|
||||
usePlaceholder(codemirror)
|
||||
|
|
@ -21,6 +23,7 @@ function createCodeMirror() {
|
|||
useMarkdown(codemirror)
|
||||
useGfm(codemirror)
|
||||
useContinuelist(codemirror)
|
||||
/* eslint-enable react-hooks/rules-of-hooks */
|
||||
return codemirror
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue