Phase 5: 实体属性扩展功能

- 数据库层:
  - 新增 entity_attributes 表存储自定义属性
  - 新增 attribute_templates 表管理属性模板
  - 新增 attribute_history 表记录属性变更历史

- 后端 API:
  - GET/POST /api/v1/projects/{id}/attribute-templates - 属性模板管理
  - GET/POST/PUT/DELETE /api/v1/entities/{id}/attributes - 实体属性 CRUD
  - GET /api/v1/entities/{id}/attributes/history - 属性变更历史
  - GET /api/v1/projects/{id}/entities/search-by-attributes - 属性筛选搜索

- 前端 UI:
  - 实体详情面板添加属性展示
  - 属性编辑表单(支持文本、数字、日期、单选、多选)
  - 属性模板管理界面
  - 属性变更历史查看
  - 知识库实体卡片显示属性预览
  - 属性筛选搜索栏
This commit is contained in:
OpenClaw Bot
2026-02-20 00:10:49 +08:00
parent 626fa7e1c0
commit 7b67f3756e
7 changed files with 2209 additions and 27 deletions

View File

@@ -100,7 +100,10 @@ Phase 5: 高级功能 - **进行中 🚧**
- 智能项目总结 (全面/高管/技术/风险)
- 实体关联路径发现
- 前端推理面板 UI
- [ ] 实体属性扩展
- [x] 实体属性扩展 ✅ (2026-02-20 完成)
- 数据库层: 新增 `entity_attributes`, `attribute_templates`, `attribute_history`
- 后端 API: 属性模板 CRUD, 实体属性 CRUD, 属性历史查询, 属性筛选搜索
- 前端 UI: 实体属性管理弹窗, 属性模板管理, 属性变更历史查看, 知识库属性筛选
- [x] 时间线视图 ✅ (2026-02-19 完成)
- [ ] 导出功能
- [ ] 协作功能
@@ -120,6 +123,25 @@ Phase 5: 高级功能 - **进行中 🚧**
## 最近更新
### 2026-02-20
- 完成 Phase 5 实体属性扩展功能
- 数据库层:
- 新增 `entity_attributes` 表存储自定义属性
- 新增 `attribute_templates` 表管理属性模板
- 新增 `attribute_history` 表记录属性变更历史
- 后端 API:
- `GET/POST /api/v1/projects/{id}/attribute-templates` - 属性模板管理
- `GET/POST/PUT/DELETE /api/v1/entities/{id}/attributes` - 实体属性 CRUD
- `GET /api/v1/entities/{id}/attributes/history` - 属性变更历史
- `GET /api/v1/projects/{id}/entities/search-by-attributes` - 属性筛选搜索
- 前端 UI:
- 实体详情面板添加属性展示
- 属性编辑表单(支持文本、数字、日期、单选、多选)
- 属性模板管理界面
- 属性变更历史查看
- 知识库实体卡片显示属性预览
- 属性筛选搜索栏
### 2026-02-19 (晚间)
- 完成 Phase 5 知识推理与问答增强功能
- 新增 knowledge_reasoner.py 推理引擎

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@ from fastapi import FastAPI, File, UploadFile, HTTPException, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from typing import List, Optional
from typing import List, Optional, Union
from datetime import datetime
# Add backend directory to path for imports
@@ -755,14 +755,18 @@ async def get_knowledge_base(project_id: str):
# 获取术语表
glossary = db.list_glossary(project_id)
# 构建实体统计
# 构建实体统计和属性
entity_stats = {}
entity_attributes = {}
for ent in entities:
mentions = db.get_entity_mentions(ent.id)
entity_stats[ent.id] = {
"mention_count": len(mentions),
"transcript_ids": list(set([m.transcript_id for m in mentions]))
}
# Phase 5: 获取实体属性
attrs = db.get_entity_attributes(ent.id)
entity_attributes[ent.id] = attrs
# 构建实体名称映射
entity_map = {e.id: e.name for e in entities}
@@ -787,7 +791,8 @@ async def get_knowledge_base(project_id: str):
"definition": e.definition,
"aliases": e.aliases,
"mention_count": entity_stats.get(e.id, {}).get("mention_count", 0),
"appears_in": entity_stats.get(e.id, {}).get("transcript_ids", [])
"appears_in": entity_stats.get(e.id, {}).get("transcript_ids", []),
"attributes": entity_attributes.get(e.id, []) # Phase 5: 包含属性
}
for e in entities
],
@@ -1498,9 +1503,361 @@ async def project_summary(project_id: str, req: SummaryRequest):
"project_id": project_id,
"summary_type": req.summary_type,
**summary
**summary
}
# ==================== Phase 5: 实体属性扩展 API ====================
class AttributeTemplateCreate(BaseModel):
name: str
type: str # text, number, date, select, multiselect, boolean
options: Optional[List[str]] = None
default_value: Optional[str] = ""
description: Optional[str] = ""
is_required: bool = False
display_order: int = 0
class AttributeTemplateUpdate(BaseModel):
name: Optional[str] = None
type: Optional[str] = None
options: Optional[List[str]] = None
default_value: Optional[str] = None
description: Optional[str] = None
is_required: Optional[bool] = None
display_order: Optional[int] = None
class EntityAttributeSet(BaseModel):
template_id: str
value: str
change_reason: Optional[str] = ""
class EntityAttributeBatchSet(BaseModel):
attributes: List[EntityAttributeSet]
change_reason: Optional[str] = ""
# 属性模板管理 API
@app.post("/api/v1/projects/{project_id}/attribute-templates")
async def create_attribute_template_endpoint(project_id: str, template: AttributeTemplateCreate):
"""创建属性模板"""
if not DB_AVAILABLE:
raise HTTPException(status_code=500, detail="Database not available")
from db_manager import AttributeTemplate
db = get_db_manager()
project = db.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
new_template = AttributeTemplate(
id=str(uuid.uuid4())[:8],
project_id=project_id,
name=template.name,
type=template.type,
options=template.options or [],
default_value=template.default_value or "",
description=template.description or "",
is_required=template.is_required,
display_order=template.display_order
)
db.create_attribute_template(new_template)
return {
"id": new_template.id,
"name": new_template.name,
"type": new_template.type,
"success": True
}
@app.get("/api/v1/projects/{project_id}/attribute-templates")
async def list_attribute_templates_endpoint(project_id: str):
"""列出项目的所有属性模板"""
if not DB_AVAILABLE:
raise HTTPException(status_code=500, detail="Database not available")
db = get_db_manager()
templates = db.list_attribute_templates(project_id)
return [
{
"id": t.id,
"name": t.name,
"type": t.type,
"options": t.options,
"default_value": t.default_value,
"description": t.description,
"is_required": t.is_required,
"display_order": t.display_order
}
for t in templates
]
@app.get("/api/v1/attribute-templates/{template_id}")
async def get_attribute_template_endpoint(template_id: str):
"""获取属性模板详情"""
if not DB_AVAILABLE:
raise HTTPException(status_code=500, detail="Database not available")
db = get_db_manager()
template = db.get_attribute_template(template_id)
if not template:
raise HTTPException(status_code=404, detail="Template not found")
return {
"id": template.id,
"name": template.name,
"type": template.type,
"options": template.options,
"default_value": template.default_value,
"description": template.description,
"is_required": template.is_required,
"display_order": template.display_order
}
@app.put("/api/v1/attribute-templates/{template_id}")
async def update_attribute_template_endpoint(template_id: str, update: AttributeTemplateUpdate):
"""更新属性模板"""
if not DB_AVAILABLE:
raise HTTPException(status_code=500, detail="Database not available")
db = get_db_manager()
template = db.get_attribute_template(template_id)
if not template:
raise HTTPException(status_code=404, detail="Template not found")
update_data = {k: v for k, v in update.dict().items() if v is not None}
updated = db.update_attribute_template(template_id, **update_data)
return {
"id": updated.id,
"name": updated.name,
"type": updated.type,
"success": True
}
@app.delete("/api/v1/attribute-templates/{template_id}")
async def delete_attribute_template_endpoint(template_id: str):
"""删除属性模板"""
if not DB_AVAILABLE:
raise HTTPException(status_code=500, detail="Database not available")
db = get_db_manager()
db.delete_attribute_template(template_id)
return {"success": True, "message": f"Template {template_id} deleted"}
# 实体属性值管理 API
@app.post("/api/v1/entities/{entity_id}/attributes")
async def set_entity_attribute_endpoint(entity_id: str, attr: EntityAttributeSet):
"""设置实体属性值"""
if not DB_AVAILABLE:
raise HTTPException(status_code=500, detail="Database not available")
from db_manager import EntityAttribute
db = get_db_manager()
entity = db.get_entity(entity_id)
if not entity:
raise HTTPException(status_code=404, detail="Entity not found")
# 验证模板存在
template = db.get_attribute_template(attr.template_id)
if not template:
raise HTTPException(status_code=404, detail="Attribute template not found")
new_attr = EntityAttribute(
id=str(uuid.uuid4())[:8],
entity_id=entity_id,
template_id=attr.template_id,
value=attr.value
)
db.set_entity_attribute(new_attr, changed_by="user", change_reason=attr.change_reason)
return {
"entity_id": entity_id,
"template_id": attr.template_id,
"template_name": template.name,
"value": attr.value,
"success": True
}
@app.post("/api/v1/entities/{entity_id}/attributes/batch")
async def batch_set_entity_attributes_endpoint(entity_id: str, batch: EntityAttributeBatchSet):
"""批量设置实体属性值"""
if not DB_AVAILABLE:
raise HTTPException(status_code=500, detail="Database not available")
from db_manager import EntityAttribute
db = get_db_manager()
entity = db.get_entity(entity_id)
if not entity:
raise HTTPException(status_code=404, detail="Entity not found")
results = []
for attr_data in batch.attributes:
template = db.get_attribute_template(attr_data.template_id)
if template:
new_attr = EntityAttribute(
id=str(uuid.uuid4())[:8],
entity_id=entity_id,
template_id=attr_data.template_id,
value=attr_data.value
)
db.set_entity_attribute(new_attr, changed_by="user",
change_reason=batch.change_reason or "批量更新")
results.append({
"template_id": attr_data.template_id,
"template_name": template.name,
"value": attr_data.value
})
return {
"entity_id": entity_id,
"updated_count": len(results),
"attributes": results,
"success": True
}
@app.get("/api/v1/entities/{entity_id}/attributes")
async def get_entity_attributes_endpoint(entity_id: str):
"""获取实体的所有属性值"""
if not DB_AVAILABLE:
raise HTTPException(status_code=500, detail="Database not available")
db = get_db_manager()
entity = db.get_entity(entity_id)
if not entity:
raise HTTPException(status_code=404, detail="Entity not found")
attrs = db.get_entity_attributes(entity_id)
return [
{
"id": a.id,
"template_id": a.template_id,
"template_name": a.template_name,
"template_type": a.template_type,
"value": a.value
}
for a in attrs
]
@app.delete("/api/v1/entities/{entity_id}/attributes/{template_id}")
async def delete_entity_attribute_endpoint(entity_id: str, template_id: str,
reason: Optional[str] = ""):
"""删除实体属性值"""
if not DB_AVAILABLE:
raise HTTPException(status_code=500, detail="Database not available")
db = get_db_manager()
db.delete_entity_attribute(entity_id, template_id,
changed_by="user", change_reason=reason)
return {"success": True, "message": "Attribute deleted"}
# 属性历史 API
@app.get("/api/v1/entities/{entity_id}/attributes/history")
async def get_entity_attribute_history_endpoint(entity_id: str, limit: int = 50):
"""获取实体的属性变更历史"""
if not DB_AVAILABLE:
raise HTTPException(status_code=500, detail="Database not available")
db = get_db_manager()
history = db.get_attribute_history(entity_id=entity_id, limit=limit)
return [
{
"id": h.id,
"template_id": h.template_id,
"template_name": h.template_name,
"old_value": h.old_value,
"new_value": h.new_value,
"changed_by": h.changed_by,
"changed_at": h.changed_at,
"change_reason": h.change_reason
}
for h in history
]
@app.get("/api/v1/attribute-templates/{template_id}/history")
async def get_template_history_endpoint(template_id: str, limit: int = 50):
"""获取属性模板的所有变更历史(跨实体)"""
if not DB_AVAILABLE:
raise HTTPException(status_code=500, detail="Database not available")
db = get_db_manager()
history = db.get_attribute_history(template_id=template_id, limit=limit)
return [
{
"id": h.id,
"entity_id": h.entity_id,
"template_name": h.template_name,
"old_value": h.old_value,
"new_value": h.new_value,
"changed_by": h.changed_by,
"changed_at": h.changed_at,
"change_reason": h.change_reason
}
for h in history
]
# 属性筛选搜索 API
@app.get("/api/v1/projects/{project_id}/entities/search-by-attributes")
async def search_entities_by_attributes_endpoint(
project_id: str,
attribute_filter: Optional[str] = None # JSON 格式: {"职位": "经理", "部门": "技术部"}
):
"""根据属性筛选搜索实体"""
if not DB_AVAILABLE:
raise HTTPException(status_code=500, detail="Database not available")
db = get_db_manager()
project = db.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
filters = {}
if attribute_filter:
try:
filters = json.loads(attribute_filter)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid attribute_filter JSON")
entities = db.search_entities_by_attributes(project_id, filters)
return [
{
"id": e.id,
"name": e.name,
"type": e.type,
"definition": e.definition,
"attributes": e.attributes
}
for e in entities
]
# Serve frontend - MUST be last to not override API routes
app.mount("/", StaticFiles(directory="frontend", html=True), name="frontend")

View File

@@ -75,6 +75,94 @@ CREATE TABLE IF NOT EXISTS glossary (
FOREIGN KEY (project_id) REFERENCES projects(id)
);
-- Phase 5: 属性模板表
CREATE TABLE IF NOT EXISTS attribute_templates (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL, -- text/number/date/select/multiselect
description TEXT,
options TEXT, -- JSON 数组,用于 select/multiselect 类型
is_required INTEGER DEFAULT 0,
default_value TEXT,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id)
);
-- Phase 5: 实体属性值表
CREATE TABLE IF NOT EXISTS entity_attributes (
id TEXT PRIMARY KEY,
entity_id TEXT NOT NULL,
template_id TEXT,
name TEXT NOT NULL,
type TEXT NOT NULL, -- text/number/date/select/multiselect
value TEXT, -- 存储实际值
options TEXT, -- JSON 数组,用于 select/multiselect
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE,
FOREIGN KEY (template_id) REFERENCES attribute_templates(id) ON DELETE SET NULL,
UNIQUE(entity_id, name)
);
-- Phase 5: 属性变更历史表
CREATE TABLE IF NOT EXISTS attribute_history (
id TEXT PRIMARY KEY,
entity_id TEXT NOT NULL,
attribute_name TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT, -- 用户ID或系统
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
change_reason TEXT,
FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE
);
-- Phase 5: 属性模板表(项目级自定义属性定义)
CREATE TABLE IF NOT EXISTS attribute_templates (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
name TEXT NOT NULL, -- 属性名称,如"年龄"、"职位"
type TEXT NOT NULL, -- 属性类型: text, number, date, select, multiselect, boolean
options TEXT, -- JSON 数组,用于 select/multiselect 类型
default_value TEXT, -- 默认值
description TEXT, -- 属性描述
is_required BOOLEAN DEFAULT 0, -- 是否必填
display_order INTEGER DEFAULT 0, -- 显示顺序
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id)
);
-- Phase 5: 实体属性值表
CREATE TABLE IF NOT EXISTS entity_attributes (
id TEXT PRIMARY KEY,
entity_id TEXT NOT NULL,
template_id TEXT NOT NULL,
value TEXT, -- 属性值以JSON或字符串形式存储
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE,
FOREIGN KEY (template_id) REFERENCES attribute_templates(id) ON DELETE CASCADE,
UNIQUE(entity_id, template_id) -- 每个实体每个属性只能有一个值
);
-- Phase 5: 属性变更历史表
CREATE TABLE IF NOT EXISTS attribute_history (
id TEXT PRIMARY KEY,
entity_id TEXT NOT NULL,
template_id TEXT NOT NULL,
old_value TEXT,
new_value TEXT,
changed_by TEXT, -- 用户ID或"system"
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
change_reason TEXT, -- 变更原因
FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE,
FOREIGN KEY (template_id) REFERENCES attribute_templates(id) ON DELETE CASCADE
);
-- 创建索引以提高查询性能
CREATE INDEX IF NOT EXISTS idx_entities_project ON entities(project_id);
CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name);
@@ -83,3 +171,15 @@ CREATE INDEX IF NOT EXISTS idx_mentions_entity ON entity_mentions(entity_id);
CREATE INDEX IF NOT EXISTS idx_mentions_transcript ON entity_mentions(transcript_id);
CREATE INDEX IF NOT EXISTS idx_relations_project ON entity_relations(project_id);
CREATE INDEX IF NOT EXISTS idx_glossary_project ON glossary(project_id);
-- Phase 5: 属性相关索引
CREATE INDEX IF NOT EXISTS idx_attr_templates_project ON attribute_templates(project_id);
CREATE INDEX IF NOT EXISTS idx_entity_attributes_entity ON entity_attributes(entity_id);
CREATE INDEX IF NOT EXISTS idx_entity_attributes_template ON entity_attributes(template_id);
CREATE INDEX IF NOT EXISTS idx_attr_history_entity ON attribute_history(entity_id);
-- Phase 5: 属性相关索引
CREATE INDEX IF NOT EXISTS idx_attr_templates_project ON attribute_templates(project_id);
CREATE INDEX IF NOT EXISTS idx_entity_attributes_entity ON entity_attributes(entity_id);
CREATE INDEX IF NOT EXISTS idx_entity_attributes_template ON entity_attributes(template_id);
CREATE INDEX IF NOT EXISTS idx_attr_history_entity ON attribute_history(entity_id);

View File

@@ -995,6 +995,22 @@ async function loadKnowledgeBase() {
switchView('workbench');
setTimeout(() => selectEntity(ent.id), 100);
};
// 渲染属性预览
let attrsHtml = '';
if (ent.attributes && ent.attributes.length > 0) {
attrsHtml = `
<div style="margin-top:8px; padding-top:8px; border-top:1px solid #222;">
${ent.attributes.slice(0, 3).map(a => `
<span style="display:inline-block; background:#1a1a1a; padding:2px 8px; border-radius:4px; font-size:0.75rem; margin-right:6px; margin-bottom:4px;">
${a.name}: <span style="color:#00d4ff;">${Array.isArray(a.value) ? a.value.join(', ') : a.value}</span>
</span>
`).join('')}
${ent.attributes.length > 3 ? `<span style="font-size:0.75rem; color:#666;">+${ent.attributes.length - 3}</span>` : ''}
</div>
`;
}
card.innerHTML = `
<div class="kb-entity-name">
<span class="entity-type-badge type-${ent.type.toLowerCase()}" style="font-size:0.65rem;margin-right:8px;">${ent.type}</span>
@@ -1005,6 +1021,7 @@ async function loadKnowledgeBase() {
📍 ${ent.mention_count || 0} 次提及 ·
${ent.appears_in?.length || 0} 个文件
</div>
${attrsHtml}
`;
entityGrid.appendChild(card);
});
@@ -1342,36 +1359,453 @@ window.findInferencePath = async function(startEntity, endEntity) {
}
};
function renderInferencePaths(data) {
const pathsList = document.getElementById('inferencePathsList');
// Phase 5: Entity Attributes Management
let currentEntityIdForAttributes = null;
let currentAttributes = [];
let currentTemplates = [];
if (!data.paths || data.paths.length === 0) {
pathsList.innerHTML = '<p style="color:#666;">未找到关联路径</p>';
// Show entity attributes modal
window.showEntityAttributes = async function(entityId) {
if (entityId) {
currentEntityIdForAttributes = entityId;
} else if (selectedEntity) {
currentEntityIdForAttributes = selectedEntity;
} else {
alert('请先选择一个实体');
return;
}
pathsList.innerHTML = data.paths.map((path, idx) => {
const strengthPercent = Math.round(path.strength * 100);
const pathHtml = path.path.map((node, i) => {
if (i === 0) {
return `<span class="path-node">${node.entity}</span>`;
const modal = document.getElementById('attributesModal');
modal.classList.add('show');
// Reset form
document.getElementById('attributesAddForm').style.display = 'none';
document.getElementById('toggleAddAttrBtn').style.display = 'inline-block';
document.getElementById('saveAttrBtn').style.display = 'none';
await loadEntityAttributes();
};
window.hideAttributesModal = function() {
document.getElementById('attributesModal').classList.remove('show');
currentEntityIdForAttributes = null;
};
async function loadEntityAttributes() {
if (!currentEntityIdForAttributes) return;
try {
const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes`);
if (!res.ok) throw new Error('Failed to load attributes');
const data = await res.json();
currentAttributes = data.attributes || [];
renderAttributesList();
} catch (err) {
console.error('Load attributes failed:', err);
document.getElementById('attributesList').innerHTML = '<p style="color:#ff6b6b;">加载失败</p>';
}
}
function renderAttributesList() {
const container = document.getElementById('attributesList');
if (currentAttributes.length === 0) {
container.innerHTML = '<p style="color:#666; text-align:center; padding:20px;">暂无属性,点击"添加属性"创建</p>';
return;
}
container.innerHTML = currentAttributes.map(attr => {
let valueDisplay = attr.value;
if (attr.type === 'multiselect' && Array.isArray(attr.value)) {
valueDisplay = attr.value.join(', ');
}
return `
<span class="path-arrow">→ ${node.relation} →</span>
<span class="path-node">${node.entity}</span>
`;
}).join('');
return `
<div class="inference-path">
<div class="inference-path-header">
<span style="color:#888;font-size:0.85rem;">路径 ${idx + 1}</span>
<span class="path-strength">关联强度: ${strengthPercent}%</span>
<div class="attribute-item">
<div class="attribute-info">
<div class="attribute-name">
${attr.name}
<span class="attribute-type">${attr.type}</span>
</div>
<div class="path-visual">
${pathHtml}
<div class="attribute-value">${valueDisplay || '-'}</div>
</div>
<div class="attribute-actions">
<button class="attribute-btn" onclick="showAttributeHistory('${attr.name}')">历史</button>
<button class="attribute-btn delete" onclick="deleteAttribute('${attr.id}')">删除</button>
</div>
</div>
`;
}).join('');
}
window.toggleAddAttributeForm = function() {
const form = document.getElementById('attributesAddForm');
const toggleBtn = document.getElementById('toggleAddAttrBtn');
const saveBtn = document.getElementById('saveAttrBtn');
if (form.style.display === 'none') {
form.style.display = 'block';
toggleBtn.style.display = 'none';
saveBtn.style.display = 'inline-block';
} else {
form.style.display = 'none';
toggleBtn.style.display = 'inline-block';
saveBtn.style.display = 'none';
}
};
window.onAttrTypeChange = function() {
const type = document.getElementById('attrType').value;
const optionsGroup = document.getElementById('attrOptionsGroup');
const valueContainer = document.getElementById('attrValueContainer');
if (type === 'select' || type === 'multiselect') {
optionsGroup.style.display = 'block';
} else {
optionsGroup.style.display = 'none';
}
// Update value input based on type
if (type === 'date') {
valueContainer.innerHTML = '<input type="date" id="attrValue">';
} else if (type === 'number') {
valueContainer.innerHTML = '<input type="number" id="attrValue" placeholder="输入数字">';
} else {
valueContainer.innerHTML = '<input type="text" id="attrValue" placeholder="输入属性值">';
}
};
window.saveAttribute = async function() {
if (!currentEntityIdForAttributes) return;
const name = document.getElementById('attrName').value.trim();
const type = document.getElementById('attrType').value;
let value = document.getElementById('attrValue').value;
const changeReason = document.getElementById('attrChangeReason').value.trim();
if (!name) {
alert('请输入属性名称');
return;
}
// Handle options for select/multiselect
let options = null;
if (type === 'select' || type === 'multiselect') {
const optionsStr = document.getElementById('attrOptions').value.trim();
if (optionsStr) {
options = optionsStr.split(',').map(o => o.trim()).filter(o => o);
}
// Handle multiselect value
if (type === 'multiselect' && value) {
value = value.split(',').map(v => v.trim()).filter(v => v);
}
}
// Handle number type
if (type === 'number' && value) {
value = parseFloat(value);
}
try {
const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
type,
value,
options,
change_reason: changeReason
})
});
if (!res.ok) throw new Error('Failed to save attribute');
// Reset form
document.getElementById('attrName').value = '';
document.getElementById('attrValue').value = '';
document.getElementById('attrOptions').value = '';
document.getElementById('attrChangeReason').value = '';
// Reload attributes
await loadEntityAttributes();
// Hide form
toggleAddAttributeForm();
} catch (err) {
console.error('Save attribute failed:', err);
alert('保存失败,请重试');
}
};
window.deleteAttribute = async function(attributeId) {
if (!confirm('确定要删除这个属性吗?')) return;
try {
const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes/${attributeId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Failed to delete attribute');
await loadEntityAttributes();
} catch (err) {
console.error('Delete attribute failed:', err);
alert('删除失败');
}
};
// Attribute History
window.showAttributeHistory = async function(attributeName) {
if (!currentEntityIdForAttributes) return;
const modal = document.getElementById('attrHistoryModal');
modal.classList.add('show');
try {
const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes/history?attribute_name=${encodeURIComponent(attributeName)}`);
if (!res.ok) throw new Error('Failed to load history');
const data = await res.json();
renderAttributeHistory(data.history, attributeName);
} catch (err) {
console.error('Load history failed:', err);
document.getElementById('attrHistoryContent').innerHTML = '<p style="color:#ff6b6b;">加载失败</p>';
}
};
window.hideAttrHistoryModal = function() {
document.getElementById('attrHistoryModal').classList.remove('show');
};
function renderAttributeHistory(history, attributeName) {
const container = document.getElementById('attrHistoryContent');
if (history.length === 0) {
container.innerHTML = `<p style="color:#666; text-align:center;">属性 "${attributeName}" 暂无变更历史</p>`;
return;
}
container.innerHTML = history.map(h => {
const date = new Date(h.changed_at).toLocaleString();
return `
<div class="history-item">
<div class="history-header">
<span>${h.changed_by || '系统'}</span>
<span>${date}</span>
</div>
<div class="history-change">
<span class="history-old">${h.old_value || '(无)'}</span>
<span class="history-arrow">→</span>
<span class="history-new">${h.new_value || '(无)'}</span>
</div>
${h.change_reason ? `<div style="color:#666; margin-top:4px; font-size:0.8rem;">原因: ${h.change_reason}</div>` : ''}
</div>
`;
}).join('');
}
// Attribute Templates Management
window.showAttributeTemplates = async function() {
const modal = document.getElementById('attrTemplatesModal');
modal.classList.add('show');
document.getElementById('templateForm').style.display = 'none';
document.getElementById('toggleTemplateBtn').style.display = 'inline-block';
document.getElementById('saveTemplateBtn').style.display = 'none';
await loadAttributeTemplates();
};
window.hideAttrTemplatesModal = function() {
document.getElementById('attrTemplatesModal').classList.remove('show');
};
async function loadAttributeTemplates() {
if (!currentProject) return;
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/attribute-templates`);
if (!res.ok) throw new Error('Failed to load templates');
currentTemplates = await res.json();
renderTemplatesList();
} catch (err) {
console.error('Load templates failed:', err);
document.getElementById('templatesList').innerHTML = '<p style="color:#ff6b6b;">加载失败</p>';
}
}
function renderTemplatesList() {
const container = document.getElementById('templatesList');
if (currentTemplates.length === 0) {
container.innerHTML = '<p style="color:#666; text-align:center; padding:20px;">暂无模板,点击"新建模板"创建</p>';
return;
}
container.innerHTML = currentTemplates.map(t => {
const optionsStr = t.options ? `选项: ${t.options.join(', ')}` : '';
return `
<div class="template-item">
<div class="template-info">
<div class="template-name">
${t.name}
<span class="attribute-type">${t.type}</span>
${t.is_required ? '<span style="color:#ff6b6b;">*</span>' : ''}
</div>
<div class="template-desc">${t.description || ''} ${optionsStr}</div>
</div>
<div class="attribute-actions">
<button class="attribute-btn delete" onclick="deleteTemplate('${t.id}')">删除</button>
</div>
</div>
`;
}).join('');
}
window.toggleTemplateForm = function() {
const form = document.getElementById('templateForm');
const toggleBtn = document.getElementById('toggleTemplateBtn');
const saveBtn = document.getElementById('saveTemplateBtn');
if (form.style.display === 'none') {
form.style.display = 'block';
toggleBtn.style.display = 'none';
saveBtn.style.display = 'inline-block';
} else {
form.style.display = 'none';
toggleBtn.style.display = 'inline-block';
saveBtn.style.display = 'none';
}
};
window.onTemplateTypeChange = function() {
const type = document.getElementById('templateType').value;
const optionsGroup = document.getElementById('templateOptionsGroup');
if (type === 'select' || type === 'multiselect') {
optionsGroup.style.display = 'block';
} else {
optionsGroup.style.display = 'none';
}
};
window.saveTemplate = async function() {
if (!currentProject) return;
const name = document.getElementById('templateName').value.trim();
const type = document.getElementById('templateType').value;
const description = document.getElementById('templateDesc').value.trim();
const isRequired = document.getElementById('templateRequired').checked;
const defaultValue = document.getElementById('templateDefault').value.trim();
if (!name) {
alert('请输入模板名称');
return;
}
let options = null;
if (type === 'select' || type === 'multiselect') {
const optionsStr = document.getElementById('templateOptions').value.trim();
if (optionsStr) {
options = optionsStr.split(',').map(o => o.trim()).filter(o => o);
}
}
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/attribute-templates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
type,
description,
options,
is_required: isRequired,
default_value: defaultValue || null
})
});
if (!res.ok) throw new Error('Failed to save template');
// Reset form
document.getElementById('templateName').value = '';
document.getElementById('templateDesc').value = '';
document.getElementById('templateOptions').value = '';
document.getElementById('templateDefault').value = '';
document.getElementById('templateRequired').checked = false;
await loadAttributeTemplates();
toggleTemplateForm();
} catch (err) {
console.error('Save template failed:', err);
alert('保存失败');
}
};
window.deleteTemplate = async function(templateId) {
if (!confirm('确定要删除这个模板吗?')) return;
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/attribute-templates/${templateId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Failed to delete template');
await loadAttributeTemplates();
} catch (err) {
console.error('Delete template failed:', err);
alert('删除失败');
}
};
// Search entities by attributes
window.searchByAttributes = async function() {
if (!currentProject) return;
const filterName = document.getElementById('attrFilterName').value;
const filterValue = document.getElementById('attrFilterValue').value;
const filterOp = document.getElementById('attrFilterOp').value;
if (!filterName || !filterValue) {
alert('请输入筛选条件');
return;
}
try {
const filters = JSON.stringify([{ name: filterName, value: filterValue, operator: filterOp }]);
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/entities/search-by-attributes?filters=${encodeURIComponent(filters)}`);
if (!res.ok) throw new Error('Search failed');
const entities = await res.json();
// Update entity grid
const grid = document.getElementById('kbEntityGrid');
if (entities.length === 0) {
grid.innerHTML = '<p style="color:#666; grid-column:1/-1; text-align:center;">未找到匹配的实体</p>';
return;
}
grid.innerHTML = entities.map(ent => `
<div class="kb-entity-card" onclick="switchView('workbench'); setTimeout(() => selectEntity('${ent.id}'), 100);">
<div class="kb-entity-name">
<span class="entity-type-badge type-${ent.type.toLowerCase()}" style="font-size:0.65rem;margin-right:8px;">${ent.type}</span>
${ent.name}
</div>
<div class="kb-entity-def">${ent.definition || '暂无定义'}</div>
</div>
`).join('');
} catch (err) {
console.error('Search by attributes failed:', err);
alert('搜索失败');
}
};

View File

@@ -1270,6 +1270,142 @@
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-10px); }
}
/* Phase 5: Entity Attributes */
.attributes-list {
max-height: 300px;
overflow-y: auto;
}
.attribute-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: #0a0a0a;
border-radius: 6px;
margin-bottom: 8px;
}
.attribute-info {
flex: 1;
}
.attribute-name {
font-weight: 500;
color: #e0e0e0;
margin-bottom: 4px;
}
.attribute-value {
color: #00d4ff;
font-size: 0.9rem;
}
.attribute-type {
font-size: 0.7rem;
color: #666;
margin-left: 8px;
}
.attribute-actions {
display: flex;
gap: 8px;
}
.attribute-btn {
background: transparent;
border: 1px solid #333;
color: #888;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
}
.attribute-btn:hover {
border-color: #00d4ff;
color: #00d4ff;
}
.attribute-btn.delete:hover {
border-color: #ff6b6b;
color: #ff6b6b;
}
.attributes-add-form {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #333;
}
.templates-list {
max-height: 300px;
overflow-y: auto;
}
.template-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: #0a0a0a;
border-radius: 6px;
margin-bottom: 8px;
}
.template-info {
flex: 1;
}
.template-name {
font-weight: 500;
color: #e0e0e0;
}
.template-desc {
font-size: 0.8rem;
color: #666;
margin-top: 2px;
}
.history-item {
padding: 12px;
background: #0a0a0a;
border-radius: 6px;
margin-bottom: 8px;
font-size: 0.85rem;
}
.history-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
color: #888;
}
.history-change {
display: flex;
align-items: center;
gap: 8px;
}
.history-old {
color: #ff6b6b;
text-decoration: line-through;
}
.history-new {
color: #4ecdc4;
}
.history-arrow {
color: #666;
}
/* Phase 5: Attribute filter in KB */
.attribute-filter-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
padding: 12px;
background: #141414;
border-radius: 8px;
flex-wrap: wrap;
}
.attribute-filter-item {
display: flex;
align-items: center;
gap: 8px;
}
.attribute-filter-item select,
.attribute-filter-item input {
background: #1a1a1a;
border: 1px solid #333;
color: #e0e0e0;
padding: 6px 10px;
border-radius: 4px;
font-size: 0.85rem;
}
</style>
</head>
<body>
@@ -1441,7 +1577,32 @@
<div class="kb-main">
<!-- Entities Section -->
<div class="kb-section active" id="kbEntitiesSection">
<h3 style="margin-bottom:16px;">所有实体</h3>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h3>所有实体</h3>
<button class="btn btn-small" onclick="showAttributeTemplates()">🏷️ 管理属性模板</button>
</div>
<!-- Attribute Filter Bar -->
<div class="attribute-filter-bar">
<div class="attribute-filter-item">
<label>属性筛选:</label>
<input type="text" id="attrFilterName" placeholder="属性名 (如: 职位)" style="width:120px;">
</div>
<div class="attribute-filter-item">
<select id="attrFilterOp">
<option value="eq">等于</option>
<option value="contains">包含</option>
<option value="gt">大于</option>
<option value="lt">小于</option>
</select>
</div>
<div class="attribute-filter-item">
<input type="text" id="attrFilterValue" placeholder="属性值">
</div>
<button class="btn-icon" onclick="searchByAttributes()">🔍 搜索</button>
<button class="btn-icon" onclick="loadKnowledgeBase()">重置</button>
</div>
<div class="kb-entity-grid" id="kbEntityGrid"></div>
</div>
<!-- Relations Section -->
@@ -1581,6 +1742,114 @@
</div>
</div>
<!-- Entity Attributes Modal -->
<div class="modal-overlay" id="attributesModal">
<div class="modal" style="max-width: 600px;">
<h3 class="modal-header">实体属性</h3>
<div id="attributesContent">
<div class="attributes-list" id="attributesList"></div>
<div class="attributes-add-form" id="attributesAddForm" style="display:none;">
<h4 style="color:#888;font-size:0.9rem;margin:16px 0 12px;">添加新属性</h4>
<div class="form-group">
<label>属性名称</label>
<input type="text" id="attrName" placeholder="如: 职位、部门、年龄">
</div>
<div class="form-group">
<label>属性类型</label>
<select id="attrType" onchange="onAttrTypeChange()">
<option value="text">文本</option>
<option value="number">数字</option>
<option value="date">日期</option>
<option value="select">单选</option>
<option value="multiselect">多选</option>
</select>
</div>
<div class="form-group" id="attrOptionsGroup" style="display:none;">
<label>选项 (用逗号分隔)</label>
<input type="text" id="attrOptions" placeholder="选项1, 选项2, 选项3">
</div>
<div class="form-group">
<label>属性值</label>
<div id="attrValueContainer">
<input type="text" id="attrValue" placeholder="输入属性值">
</div>
</div>
<div class="form-group">
<label>变更原因 (可选)</label>
<input type="text" id="attrChangeReason" placeholder="为什么添加/修改这个属性">
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="hideAttributesModal()">关闭</button>
<button class="btn" onclick="toggleAddAttributeForm()" id="toggleAddAttrBtn">添加属性</button>
<button class="btn" onclick="saveAttribute()" id="saveAttrBtn" style="display:none;">保存</button>
</div>
</div>
</div>
<!-- Attribute History Modal -->
<div class="modal-overlay" id="attrHistoryModal">
<div class="modal" style="max-width: 700px;">
<h3 class="modal-header">属性变更历史</h3>
<div id="attrHistoryContent" style="max-height: 400px; overflow-y: auto;">
<p style="color:#666;">加载中...</p>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="hideAttrHistoryModal()">关闭</button>
</div>
</div>
</div>
<!-- Attribute Templates Modal -->
<div class="modal-overlay" id="attrTemplatesModal">
<div class="modal" style="max-width: 700px;">
<h3 class="modal-header">属性模板管理</h3>
<div id="attrTemplatesContent">
<div class="templates-list" id="templatesList"></div>
<div class="template-form" id="templateForm" style="display:none; margin-top: 20px; padding-top: 20px; border-top: 1px solid #333;">
<h4 style="color:#888;font-size:0.9rem;margin-bottom:12px;">新建模板</h4>
<div class="form-group">
<label>模板名称</label>
<input type="text" id="templateName" placeholder="如: 职位">
</div>
<div class="form-group">
<label>类型</label>
<select id="templateType" onchange="onTemplateTypeChange()">
<option value="text">文本</option>
<option value="number">数字</option>
<option value="date">日期</option>
<option value="select">单选</option>
<option value="multiselect">多选</option>
</select>
</div>
<div class="form-group">
<label>描述 (可选)</label>
<input type="text" id="templateDesc" placeholder="属性的用途说明">
</div>
<div class="form-group" id="templateOptionsGroup" style="display:none;">
<label>选项 (用逗号分隔)</label>
<input type="text" id="templateOptions" placeholder="选项1, 选项2, 选项3">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="templateRequired"> 必填属性
</label>
</div>
<div class="form-group">
<label>默认值 (可选)</label>
<input type="text" id="templateDefault" placeholder="默认值">
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="hideAttrTemplatesModal()">关闭</button>
<button class="btn" onclick="toggleTemplateForm()" id="toggleTemplateBtn">新建模板</button>
<button class="btn" onclick="saveTemplate()" id="saveTemplateBtn" style="display:none;">保存模板</button>
</div>
</div>
</div>
<!-- Entity Editor Modal -->
<div class="modal-overlay" id="entityModal">
<div class="modal">
@@ -1691,6 +1960,7 @@
<!-- Context Menu -->
<div class="context-menu" id="contextMenu">
<div class="context-menu-item" onclick="editEntity()">✏️ 编辑实体</div>
<div class="context-menu-item" onclick="showEntityAttributes()">🏷️ 管理属性</div>
<div class="context-menu-item" onclick="showMergeModal()">🔄 合并实体</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item" onclick="createEntityFromSelection()"> 标记为实体</div>