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:
24
STATUS.md
24
STATUS.md
@@ -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 推理引擎
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
363
backend/main.py
363
backend/main.py
@@ -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")
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
478
frontend/app.js
478
frontend/app.js
@@ -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');
|
||||
|
||||
if (!data.paths || data.paths.length === 0) {
|
||||
pathsList.innerHTML = '<p style="color:#666;">未找到关联路径</p>';
|
||||
// Phase 5: Entity Attributes Management
|
||||
let currentEntityIdForAttributes = null;
|
||||
let currentAttributes = [];
|
||||
let currentTemplates = [];
|
||||
|
||||
// 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>`;
|
||||
}
|
||||
return `
|
||||
<span class="path-arrow">→ ${node.relation} →</span>
|
||||
<span class="path-node">${node.entity}</span>
|
||||
`;
|
||||
}).join('');
|
||||
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 `
|
||||
<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="attribute-value">${valueDisplay || '-'}</div>
|
||||
</div>
|
||||
<div class="path-visual">
|
||||
${pathHtml}
|
||||
<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('搜索失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user