diff --git a/STATUS.md b/STATUS.md index 78b6549..7d66e1f 100644 --- a/STATUS.md +++ b/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 推理引擎 diff --git a/backend/__pycache__/main.cpython-312.pyc b/backend/__pycache__/main.cpython-312.pyc index 25b2b6e..96bd360 100644 Binary files a/backend/__pycache__/main.cpython-312.pyc and b/backend/__pycache__/main.cpython-312.pyc differ diff --git a/backend/db_manager.py b/backend/db_manager.py index e7229d0..aec803e 100644 --- a/backend/db_manager.py +++ b/backend/db_manager.py @@ -31,10 +31,57 @@ class Entity: definition: str = "" canonical_name: str = "" aliases: List[str] = None + attributes: Dict = None # Phase 5: 实体属性 def __post_init__(self): if self.aliases is None: self.aliases = [] + if self.attributes is None: + self.attributes = {} + +@dataclass +class AttributeTemplate: + """属性模板定义""" + id: str + project_id: str + name: str + type: str # text, number, date, select, multiselect, boolean + options: List[str] = None # 用于 select/multiselect + default_value: str = "" + description: str = "" + is_required: bool = False + display_order: int = 0 + created_at: str = "" + updated_at: str = "" + + def __post_init__(self): + if self.options is None: + self.options = [] + +@dataclass +class EntityAttribute: + """实体属性值""" + id: str + entity_id: str + template_id: str + value: str + template_name: str = "" # 关联查询时填充 + template_type: str = "" # 关联查询时填充 + created_at: str = "" + updated_at: str = "" + +@dataclass +class AttributeHistory: + """属性变更历史""" + id: str + entity_id: str + template_id: str + template_name: str = "" + old_value: str = "" + new_value: str = "" + changed_by: str = "" + changed_at: str = "" + change_reason: str = "" @dataclass class EntityMention: @@ -755,7 +802,372 @@ class DatabaseManager: 'top_entities': [dict(e) for e in entity_stats], 'active_periods': [dict(a) for a in active_periods] } + + # Phase 5: Attribute Template operations + def create_attribute_template(self, project_id: str, template_data: dict) -> dict: + """创建属性模板""" + conn = self.get_conn() + template_id = str(uuid.uuid4())[:8] + now = datetime.now().isoformat() + + conn.execute( + """INSERT INTO attribute_templates + (id, project_id, name, type, description, options, is_required, default_value, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (template_id, project_id, template_data['name'], template_data['type'], + template_data.get('description', ''), + json.dumps(template_data.get('options', [])) if template_data.get('options') else None, + 1 if template_data.get('is_required') else 0, + template_data.get('default_value'), + template_data.get('sort_order', 0), + now, now) + ) + conn.commit() + conn.close() + return self.get_attribute_template(template_id) + def get_attribute_template(self, template_id: str) -> Optional[dict]: + """获取属性模板详情""" + conn = self.get_conn() + row = conn.execute( + "SELECT * FROM attribute_templates WHERE id = ?", + (template_id,) + ).fetchone() + conn.close() + + if row: + data = dict(row) + if data.get('options'): + data['options'] = json.loads(data['options']) + return data + return None + + def list_attribute_templates(self, project_id: str) -> List[dict]: + """列出项目的所有属性模板""" + conn = self.get_conn() + rows = conn.execute( + """SELECT * FROM attribute_templates + WHERE project_id = ? + ORDER BY sort_order, created_at""", + (project_id,) + ).fetchall() + conn.close() + + templates = [] + for row in rows: + data = dict(row) + if data.get('options'): + data['options'] = json.loads(data['options']) + templates.append(data) + return templates + + def update_attribute_template(self, template_id: str, template_data: dict) -> Optional[dict]: + """更新属性模板""" + conn = self.get_conn() + now = datetime.now().isoformat() + + allowed_fields = ['name', 'type', 'description', 'is_required', 'default_value', 'sort_order'] + updates = [] + values = [] + + for field in allowed_fields: + if field in template_data: + updates.append(f"{field} = ?") + if field == 'is_required': + values.append(1 if template_data[field] else 0) + else: + values.append(template_data[field]) + + if 'options' in template_data: + updates.append("options = ?") + values.append(json.dumps(template_data['options']) if template_data['options'] else None) + + if not updates: + conn.close() + return self.get_attribute_template(template_id) + + updates.append("updated_at = ?") + values.append(now) + values.append(template_id) + + query = f"UPDATE attribute_templates SET {', '.join(updates)} WHERE id = ?" + conn.execute(query, values) + conn.commit() + conn.close() + + return self.get_attribute_template(template_id) + + def delete_attribute_template(self, template_id: str): + """删除属性模板""" + conn = self.get_conn() + conn.execute("DELETE FROM attribute_templates WHERE id = ?", (template_id,)) + conn.commit() + conn.close() + + # Phase 5: Entity Attribute operations + def get_entity_attributes(self, entity_id: str) -> List[dict]: + """获取实体的所有属性""" + conn = self.get_conn() + rows = conn.execute( + """SELECT a.*, t.name as template_name, t.description as template_description + FROM entity_attributes a + LEFT JOIN attribute_templates t ON a.template_id = t.id + WHERE a.entity_id = ? + ORDER BY t.sort_order, a.created_at""", + (entity_id,) + ).fetchall() + conn.close() + + attributes = [] + for row in rows: + data = dict(row) + if data.get('options'): + data['options'] = json.loads(data['options']) + # 解析 value 根据 type + if data['type'] == 'number' and data['value']: + try: + data['value'] = float(data['value']) + except: + pass + elif data['type'] == 'multiselect' and data['value']: + try: + data['value'] = json.loads(data['value']) + except: + pass + attributes.append(data) + return attributes + + def get_entity_attribute(self, attribute_id: str) -> Optional[dict]: + """获取单个属性详情""" + conn = self.get_conn() + row = conn.execute( + "SELECT * FROM entity_attributes WHERE id = ?", + (attribute_id,) + ).fetchone() + conn.close() + + if row: + data = dict(row) + if data.get('options'): + data['options'] = json.loads(data['options']) + return data + return None + + def set_entity_attribute(self, entity_id: str, attr_data: dict, changed_by: str = "system") -> dict: + """设置实体属性值(创建或更新)""" + conn = self.get_conn() + now = datetime.now().isoformat() + + # 检查是否已存在 + existing = conn.execute( + "SELECT * FROM entity_attributes WHERE entity_id = ? AND name = ?", + (entity_id, attr_data['name']) + ).fetchone() + + # 处理 value 存储 + value = attr_data['value'] + if attr_data['type'] == 'multiselect' and isinstance(value, list): + value = json.dumps(value) + elif value is not None: + value = str(value) + + if existing: + # 记录历史 + old_value = existing['value'] + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, attribute_name, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], entity_id, attr_data['name'], old_value, value, + changed_by, now, attr_data.get('change_reason', '')) + ) + + # 更新属性 + conn.execute( + """UPDATE entity_attributes + SET value = ?, type = ?, options = ?, updated_at = ? + WHERE id = ?""", + (value, attr_data['type'], + json.dumps(attr_data.get('options', [])) if attr_data.get('options') else existing['options'], + now, existing['id']) + ) + attribute_id = existing['id'] + else: + # 创建新属性 + attribute_id = str(uuid.uuid4())[:8] + conn.execute( + """INSERT INTO entity_attributes + (id, entity_id, template_id, name, type, value, options, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (attribute_id, entity_id, attr_data.get('template_id'), + attr_data['name'], attr_data['type'], value, + json.dumps(attr_data.get('options', [])) if attr_data.get('options') else None, + now, now) + ) + + # 记录历史 + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, attribute_name, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], entity_id, attr_data['name'], None, value, + changed_by, now, attr_data.get('change_reason', '创建属性')) + ) + + conn.commit() + conn.close() + return self.get_entity_attribute(attribute_id) + + def update_entity_attribute(self, attribute_id: str, attr_data: dict, changed_by: str = "system") -> Optional[dict]: + """更新实体属性""" + conn = self.get_conn() + now = datetime.now().isoformat() + + existing = conn.execute( + "SELECT * FROM entity_attributes WHERE id = ?", + (attribute_id,) + ).fetchone() + + if not existing: + conn.close() + return None + + # 处理 value + value = attr_data.get('value') + if value is not None: + if attr_data.get('type', existing['type']) == 'multiselect' and isinstance(value, list): + value = json.dumps(value) + else: + value = str(value) + + # 记录历史 + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, attribute_name, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], existing['entity_id'], existing['name'], + existing['value'], value, changed_by, now, + attr_data.get('change_reason', '')) + ) + + # 更新字段 + updates = [] + values = [] + + if 'value' in attr_data: + updates.append("value = ?") + values.append(value) + + if 'type' in attr_data: + updates.append("type = ?") + values.append(attr_data['type']) + + if 'options' in attr_data: + updates.append("options = ?") + values.append(json.dumps(attr_data['options']) if attr_data['options'] else None) + + if updates: + updates.append("updated_at = ?") + values.append(now) + values.append(attribute_id) + + query = f"UPDATE entity_attributes SET {', '.join(updates)} WHERE id = ?" + conn.execute(query, values) + conn.commit() + + conn.close() + return self.get_entity_attribute(attribute_id) + + def delete_entity_attribute(self, attribute_id: str, changed_by: str = "system"): + """删除实体属性""" + conn = self.get_conn() + + existing = conn.execute( + "SELECT * FROM entity_attributes WHERE id = ?", + (attribute_id,) + ).fetchone() + + if existing: + # 记录历史 + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, attribute_name, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], existing['entity_id'], existing['name'], + existing['value'], None, changed_by, datetime.now().isoformat(), '删除属性') + ) + + conn.execute("DELETE FROM entity_attributes WHERE id = ?", (attribute_id,)) + conn.commit() + + conn.close() + + def get_attribute_history(self, entity_id: str, attribute_name: str = None) -> List[dict]: + """获取属性变更历史""" + conn = self.get_conn() + + if attribute_name: + rows = conn.execute( + """SELECT * FROM attribute_history + WHERE entity_id = ? AND attribute_name = ? + ORDER BY changed_at DESC""", + (entity_id, attribute_name) + ).fetchall() + else: + rows = conn.execute( + """SELECT * FROM attribute_history + WHERE entity_id = ? + ORDER BY changed_at DESC""", + (entity_id,) + ).fetchall() + + conn.close() + return [dict(r) for r in rows] + + def search_entities_by_attributes(self, project_id: str, filters: List[dict]) -> List[Entity]: + """根据属性筛选搜索实体""" + conn = self.get_conn() + + # 基础查询 + base_query = "SELECT DISTINCT e.* FROM entities e" + where_conditions = ["e.project_id = ?"] + params = [project_id] + + # 为每个过滤条件添加 JOIN + join_clauses = [] + for i, f in enumerate(filters): + alias = f"a{i}" + join_clauses.append( + f"JOIN entity_attributes {alias} ON e.id = {alias}.entity_id AND {alias}.name = ?" + ) + params.append(f['name']) + + operator = f.get('operator', 'eq') + if operator == 'eq': + where_conditions.append(f"{alias}.value = ?") + params.append(str(f['value'])) + elif operator == 'contains': + where_conditions.append(f"{alias}.value LIKE ?") + params.append(f"%{f['value']}%") + elif operator == 'gt': + where_conditions.append(f"CAST({alias}.value AS REAL) > ?") + params.append(float(f['value'])) + elif operator == 'lt': + where_conditions.append(f"CAST({alias}.value AS REAL) < ?") + params.append(float(f['value'])) + + query = base_query + " " + " ".join(join_clauses) + " WHERE " + " AND ".join(where_conditions) + + rows = conn.execute(query, params).fetchall() + conn.close() + + entities = [] + for row in rows: + data = dict(row) + data['aliases'] = json.loads(data['aliases']) if data['aliases'] else [] + entities.append(Entity(**data)) + return entities + def get_transcript_context(self, transcript_id: str, position: int, context_chars: int = 200) -> str: """获取转录文本的上下文""" conn = self.get_conn() @@ -773,6 +1185,593 @@ class DatabaseManager: end = min(len(text), position + context_chars) return text[start:end] + # ==================== Phase 5: 实体属性管理 ==================== + + # ---- 属性模板管理 ---- + + def create_attribute_template(self, template: AttributeTemplate) -> AttributeTemplate: + """创建属性模板""" + conn = self.get_conn() + now = datetime.now().isoformat() + conn.execute( + """INSERT INTO attribute_templates + (id, project_id, name, type, options, default_value, description, is_required, display_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (template.id, template.project_id, template.name, template.type, + json.dumps(template.options) if template.options else None, + template.default_value, template.description, template.is_required, + template.display_order, now, now) + ) + conn.commit() + conn.close() + return template + + def get_attribute_template(self, template_id: str) -> Optional[AttributeTemplate]: + """获取属性模板""" + conn = self.get_conn() + row = conn.execute( + "SELECT * FROM attribute_templates WHERE id = ?", + (template_id,) + ).fetchone() + conn.close() + if row: + data = dict(row) + data['options'] = json.loads(data['options']) if data['options'] else [] + return AttributeTemplate(**data) + return None + + def list_attribute_templates(self, project_id: str) -> List[AttributeTemplate]: + """列出项目的所有属性模板""" + conn = self.get_conn() + rows = conn.execute( + """SELECT * FROM attribute_templates + WHERE project_id = ? + ORDER BY display_order, created_at""", + (project_id,) + ).fetchall() + conn.close() + + templates = [] + for row in rows: + data = dict(row) + data['options'] = json.loads(data['options']) if data['options'] else [] + templates.append(AttributeTemplate(**data)) + return templates + + def update_attribute_template(self, template_id: str, **kwargs) -> Optional[AttributeTemplate]: + """更新属性模板""" + conn = self.get_conn() + + allowed_fields = ['name', 'type', 'options', 'default_value', + 'description', 'is_required', 'display_order'] + updates = [] + values = [] + + for field in allowed_fields: + if field in kwargs: + updates.append(f"{field} = ?") + if field == 'options': + values.append(json.dumps(kwargs[field]) if kwargs[field] else None) + else: + values.append(kwargs[field]) + + if not updates: + conn.close() + return self.get_attribute_template(template_id) + + updates.append("updated_at = ?") + values.append(datetime.now().isoformat()) + values.append(template_id) + + query = f"UPDATE attribute_templates SET {', '.join(updates)} WHERE id = ?" + conn.execute(query, values) + conn.commit() + conn.close() + + return self.get_attribute_template(template_id) + + def delete_attribute_template(self, template_id: str): + """删除属性模板(会级联删除相关属性值)""" + conn = self.get_conn() + conn.execute("DELETE FROM attribute_templates WHERE id = ?", (template_id,)) + conn.commit() + conn.close() + + # ---- 实体属性值管理 ---- + + def set_entity_attribute(self, attr: EntityAttribute, + changed_by: str = "system", + change_reason: str = "") -> EntityAttribute: + """设置实体属性值,自动记录历史""" + conn = self.get_conn() + now = datetime.now().isoformat() + + # 获取旧值 + old_row = conn.execute( + "SELECT value FROM entity_attributes WHERE entity_id = ? AND template_id = ?", + (attr.entity_id, attr.template_id) + ).fetchone() + old_value = old_row['value'] if old_row else None + + # 记录变更历史 + if old_value != attr.value: + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, template_id, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], attr.entity_id, attr.template_id, + old_value, attr.value, changed_by, now, change_reason) + ) + + # 插入或更新属性值 + conn.execute( + """INSERT OR REPLACE INTO entity_attributes + (id, entity_id, template_id, value, created_at, updated_at) + VALUES ( + COALESCE((SELECT id FROM entity_attributes WHERE entity_id = ? AND template_id = ?), ?), + ?, ?, ?, + COALESCE((SELECT created_at FROM entity_attributes WHERE entity_id = ? AND template_id = ?), ?), + ? + )""", + (attr.entity_id, attr.template_id, attr.id, + attr.entity_id, attr.template_id, attr.value, + attr.entity_id, attr.template_id, now, now) + ) + + conn.commit() + conn.close() + return attr + + def get_entity_attributes(self, entity_id: str) -> List[EntityAttribute]: + """获取实体的所有属性值""" + conn = self.get_conn() + rows = conn.execute( + """SELECT ea.*, at.name as template_name, at.type as template_type + FROM entity_attributes ea + JOIN attribute_templates at ON ea.template_id = at.id + WHERE ea.entity_id = ? + ORDER BY at.display_order""", + (entity_id,) + ).fetchall() + conn.close() + + return [EntityAttribute(**dict(r)) for r in rows] + + def get_entity_with_attributes(self, entity_id: str) -> Optional[Entity]: + """获取实体详情,包含属性""" + entity = self.get_entity(entity_id) + if not entity: + return None + + # 获取属性 + attrs = self.get_entity_attributes(entity_id) + entity.attributes = { + attr.template_name: { + 'value': attr.value, + 'type': attr.template_type, + 'template_id': attr.template_id + } + for attr in attrs + } + + return entity + + def delete_entity_attribute(self, entity_id: str, template_id: str, + changed_by: str = "system", change_reason: str = ""): + """删除实体属性值""" + conn = self.get_conn() + + # 获取旧值 + old_row = conn.execute( + "SELECT value FROM entity_attributes WHERE entity_id = ? AND template_id = ?", + (entity_id, template_id) + ).fetchone() + + if old_row: + # 记录删除历史 + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, template_id, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], entity_id, template_id, + old_row['value'], None, changed_by, datetime.now().isoformat(), + change_reason or "属性删除") + ) + + # 删除属性 + conn.execute( + "DELETE FROM entity_attributes WHERE entity_id = ? AND template_id = ?", + (entity_id, template_id) + ) + conn.commit() + + conn.close() + + # ---- 属性历史管理 ---- + + def get_attribute_history(self, entity_id: str = None, + template_id: str = None, + limit: int = 50) -> List[AttributeHistory]: + """获取属性变更历史""" + conn = self.get_conn() + + conditions = [] + params = [] + + if entity_id: + conditions.append("ah.entity_id = ?") + params.append(entity_id) + + if template_id: + conditions.append("ah.template_id = ?") + params.append(template_id) + + where_clause = " AND ".join(conditions) if conditions else "1=1" + + rows = conn.execute( + f"""SELECT ah.*, at.name as template_name + FROM attribute_history ah + JOIN attribute_templates at ON ah.template_id = at.id + WHERE {where_clause} + ORDER BY ah.changed_at DESC + LIMIT ?""", + params + [limit] + ).fetchall() + conn.close() + + return [AttributeHistory(**dict(r)) for r in rows] + + def search_entities_by_attributes(self, project_id: str, + attribute_filters: Dict[str, str]) -> List[Entity]: + """根据属性筛选搜索实体""" + conn = self.get_conn() + + # 获取项目所有实体 + entities = self.list_project_entities(project_id) + + if not attribute_filters: + return entities + + # 获取所有实体的属性 + entity_ids = [e.id for e in entities] + if not entity_ids: + return [] + + # 构建查询条件 + placeholders = ','.join(['?' for _ in entity_ids]) + rows = conn.execute( + f"""SELECT ea.*, at.name as template_name + FROM entity_attributes ea + JOIN attribute_templates at ON ea.template_id = at.id + WHERE ea.entity_id IN ({placeholders})""", + entity_ids + ).fetchall() + + conn.close() + + # 按实体ID分组属性 + entity_attrs = {} + for row in rows: + eid = row['entity_id'] + if eid not in entity_attrs: + entity_attrs[eid] = {} + entity_attrs[eid][row['template_name']] = row['value'] + + # 过滤实体 + filtered = [] + for entity in entities: + attrs = entity_attrs.get(entity.id, {}) + match = True + for attr_name, attr_value in attribute_filters.items(): + if attrs.get(attr_name) != attr_value: + match = False + break + if match: + entity.attributes = attrs + filtered.append(entity) + + return filtered + + +# Singleton instance +_db_manager = None + + +def get_db_manager() -> DatabaseManager: + global _db_manager + if _db_manager is None: + _db_manager = DatabaseManager() + return _db_manager + end = min(len(text), position + context_chars) + return text[start:end] + + # ==================== Phase 5: 实体属性管理 ==================== + + # ---- 属性模板管理 ---- + + def create_attribute_template(self, template: AttributeTemplate) -> AttributeTemplate: + """创建属性模板""" + conn = self.get_conn() + now = datetime.now().isoformat() + conn.execute( + """INSERT INTO attribute_templates + (id, project_id, name, type, options, default_value, description, is_required, display_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (template.id, template.project_id, template.name, template.type, + json.dumps(template.options) if template.options else None, + template.default_value, template.description, template.is_required, + template.display_order, now, now) + ) + conn.commit() + conn.close() + return template + + def get_attribute_template(self, template_id: str) -> Optional[AttributeTemplate]: + """获取属性模板""" + conn = self.get_conn() + row = conn.execute( + "SELECT * FROM attribute_templates WHERE id = ?", + (template_id,) + ).fetchone() + conn.close() + if row: + data = dict(row) + data['options'] = json.loads(data['options']) if data['options'] else [] + return AttributeTemplate(**data) + return None + + def list_attribute_templates(self, project_id: str) -> List[AttributeTemplate]: + """列出项目的所有属性模板""" + conn = self.get_conn() + rows = conn.execute( + """SELECT * FROM attribute_templates + WHERE project_id = ? + ORDER BY display_order, created_at""", + (project_id,) + ).fetchall() + conn.close() + + templates = [] + for row in rows: + data = dict(row) + data['options'] = json.loads(data['options']) if data['options'] else [] + templates.append(AttributeTemplate(**data)) + return templates + + def update_attribute_template(self, template_id: str, **kwargs) -> Optional[AttributeTemplate]: + """更新属性模板""" + conn = self.get_conn() + + allowed_fields = ['name', 'type', 'options', 'default_value', + 'description', 'is_required', 'display_order'] + updates = [] + values = [] + + for field in allowed_fields: + if field in kwargs: + updates.append(f"{field} = ?") + if field == 'options': + values.append(json.dumps(kwargs[field]) if kwargs[field] else None) + else: + values.append(kwargs[field]) + + if not updates: + conn.close() + return self.get_attribute_template(template_id) + + updates.append("updated_at = ?") + values.append(datetime.now().isoformat()) + values.append(template_id) + + query = f"UPDATE attribute_templates SET {', '.join(updates)} WHERE id = ?" + conn.execute(query, values) + conn.commit() + conn.close() + + return self.get_attribute_template(template_id) + + def delete_attribute_template(self, template_id: str): + """删除属性模板(会级联删除相关属性值)""" + conn = self.get_conn() + conn.execute("DELETE FROM attribute_templates WHERE id = ?", (template_id,)) + conn.commit() + conn.close() + + # ---- 实体属性值管理 ---- + + def set_entity_attribute(self, attr: EntityAttribute, + changed_by: str = "system", + change_reason: str = "") -> EntityAttribute: + """设置实体属性值,自动记录历史""" + conn = self.get_conn() + now = datetime.now().isoformat() + + # 获取旧值 + old_row = conn.execute( + "SELECT value FROM entity_attributes WHERE entity_id = ? AND template_id = ?", + (attr.entity_id, attr.template_id) + ).fetchone() + old_value = old_row['value'] if old_row else None + + # 记录变更历史 + if old_value != attr.value: + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, template_id, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], attr.entity_id, attr.template_id, + old_value, attr.value, changed_by, now, change_reason) + ) + + # 插入或更新属性值 + conn.execute( + """INSERT OR REPLACE INTO entity_attributes + (id, entity_id, template_id, value, created_at, updated_at) + VALUES ( + COALESCE((SELECT id FROM entity_attributes WHERE entity_id = ? AND template_id = ?), ?), + ?, ?, ?, + COALESCE((SELECT created_at FROM entity_attributes WHERE entity_id = ? AND template_id = ?), ?), + ? + )""", + (attr.entity_id, attr.template_id, attr.id, + attr.entity_id, attr.template_id, attr.value, + attr.entity_id, attr.template_id, now, now) + ) + + conn.commit() + conn.close() + return attr + + def get_entity_attributes(self, entity_id: str) -> List[EntityAttribute]: + """获取实体的所有属性值""" + conn = self.get_conn() + rows = conn.execute( + """SELECT ea.*, at.name as template_name, at.type as template_type + FROM entity_attributes ea + JOIN attribute_templates at ON ea.template_id = at.id + WHERE ea.entity_id = ? + ORDER BY at.display_order""", + (entity_id,) + ).fetchall() + conn.close() + + return [EntityAttribute(**dict(r)) for r in rows] + + def get_entity_with_attributes(self, entity_id: str) -> Optional[Entity]: + """获取实体详情,包含属性""" + entity = self.get_entity(entity_id) + if not entity: + return None + + # 获取属性 + attrs = self.get_entity_attributes(entity_id) + entity.attributes = { + attr.template_name: { + 'value': attr.value, + 'type': attr.template_type, + 'template_id': attr.template_id + } + for attr in attrs + } + + return entity + + def delete_entity_attribute(self, entity_id: str, template_id: str, + changed_by: str = "system", change_reason: str = ""): + """删除实体属性值""" + conn = self.get_conn() + + # 获取旧值 + old_row = conn.execute( + "SELECT value FROM entity_attributes WHERE entity_id = ? AND template_id = ?", + (entity_id, template_id) + ).fetchone() + + if old_row: + # 记录删除历史 + conn.execute( + """INSERT INTO attribute_history + (id, entity_id, template_id, old_value, new_value, changed_by, changed_at, change_reason) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (str(uuid.uuid4())[:8], entity_id, template_id, + old_row['value'], None, changed_by, datetime.now().isoformat(), + change_reason or "属性删除") + ) + + # 删除属性 + conn.execute( + "DELETE FROM entity_attributes WHERE entity_id = ? AND template_id = ?", + (entity_id, template_id) + ) + conn.commit() + + conn.close() + + # ---- 属性历史管理 ---- + + def get_attribute_history(self, entity_id: str = None, + template_id: str = None, + limit: int = 50) -> List[AttributeHistory]: + """获取属性变更历史""" + conn = self.get_conn() + + conditions = [] + params = [] + + if entity_id: + conditions.append("ah.entity_id = ?") + params.append(entity_id) + + if template_id: + conditions.append("ah.template_id = ?") + params.append(template_id) + + where_clause = " AND ".join(conditions) if conditions else "1=1" + + rows = conn.execute( + f"""SELECT ah.*, at.name as template_name + FROM attribute_history ah + JOIN attribute_templates at ON ah.template_id = at.id + WHERE {where_clause} + ORDER BY ah.changed_at DESC + LIMIT ?""", + params + [limit] + ).fetchall() + conn.close() + + return [AttributeHistory(**dict(r)) for r in rows] + + def search_entities_by_attributes(self, project_id: str, + attribute_filters: Dict[str, str]) -> List[Entity]: + """根据属性筛选搜索实体""" + conn = self.get_conn() + + # 获取项目所有实体 + entities = self.list_project_entities(project_id) + + if not attribute_filters: + return entities + + # 获取所有实体的属性 + entity_ids = [e.id for e in entities] + if not entity_ids: + return [] + + # 构建查询条件 + placeholders = ','.join(['?' for _ in entity_ids]) + rows = conn.execute( + f"""SELECT ea.*, at.name as template_name + FROM entity_attributes ea + JOIN attribute_templates at ON ea.template_id = at.id + WHERE ea.entity_id IN ({placeholders})""", + entity_ids + ).fetchall() + + conn.close() + + # 按实体ID分组属性 + entity_attrs = {} + for row in rows: + eid = row['entity_id'] + if eid not in entity_attrs: + entity_attrs[eid] = {} + entity_attrs[eid][row['template_name']] = row['value'] + + # 过滤实体 + filtered = [] + for entity in entities: + attrs = entity_attrs.get(entity.id, {}) + match = True + for attr_name, attr_value in attribute_filters.items(): + if attrs.get(attr_name) != attr_value: + match = False + break + if match: + entity.attributes = attrs + filtered.append(entity) + + return filtered + # Singleton instance _db_manager = None diff --git a/backend/main.py b/backend/main.py index efa664c..f3bd441 100644 --- a/backend/main.py +++ b/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") diff --git a/backend/schema.sql b/backend/schema.sql index 7cc1a92..f614676 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -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); diff --git a/frontend/app.js b/frontend/app.js index a88b16a..7be7949 100644 --- a/frontend/app.js +++ b/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 = ` +
未找到关联路径
'; +// 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 `${node.entity}`; - } - return ` - → ${node.relation} → - ${node.entity} - `; - }).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 = '加载失败
'; + } +} + +function renderAttributesList() { + const container = document.getElementById('attributesList'); + + if (currentAttributes.length === 0) { + container.innerHTML = '暂无属性,点击"添加属性"创建
'; + return; + } + + container.innerHTML = currentAttributes.map(attr => { + let valueDisplay = attr.value; + if (attr.type === 'multiselect' && Array.isArray(attr.value)) { + valueDisplay = attr.value.join(', '); + } return ` -加载失败
'; + } +}; + +window.hideAttrHistoryModal = function() { + document.getElementById('attrHistoryModal').classList.remove('show'); +}; + +function renderAttributeHistory(history, attributeName) { + const container = document.getElementById('attrHistoryContent'); + + if (history.length === 0) { + container.innerHTML = `属性 "${attributeName}" 暂无变更历史
`; + return; + } + + container.innerHTML = history.map(h => { + const date = new Date(h.changed_at).toLocaleString(); + return ` +加载失败
'; + } +} + +function renderTemplatesList() { + const container = document.getElementById('templatesList'); + + if (currentTemplates.length === 0) { + container.innerHTML = '暂无模板,点击"新建模板"创建
'; + return; + } + + container.innerHTML = currentTemplates.map(t => { + const optionsStr = t.options ? `选项: ${t.options.join(', ')}` : ''; + return ` +未找到匹配的实体
'; + return; + } + + grid.innerHTML = entities.map(ent => ` +