Compare commits

...

30 Commits

Author SHA1 Message Date
pycook
fe6373422e chore: release v2.4.13 2024-10-18 09:52:26 +08:00
pycook
b3ea776886 Merge pull request #628 from veops/dev_api_relation_path_search
feat(api): relation path search
2024-10-17 19:47:34 +08:00
pycook
c4d2ce313d feat(api): relation path search 2024-10-17 19:46:39 +08:00
Leo Song
20103a0fe6 Merge pull request #627 from veops/dev_ui_241017
feat(ui): add relation search
2024-10-17 17:56:13 +08:00
songlh
394e2aeac6 feat(ui): add relation search 2024-10-17 17:55:36 +08:00
pycook
8f7d78c26c Merge pull request #623 from veops/dev_api_relation_path_search
Dev api relation path search
2024-09-30 17:33:45 +08:00
pycook
7eecf3cec3 feat(api): add api /ci_type_relations/path 2024-09-26 20:32:21 +08:00
pycook
f6e9c443f7 Merge pull request #622 from novohool/master
Update cache support for environment variables in settings.example.py
2024-09-26 18:09:54 +08:00
pycook
857cbd82fd feat(api): add relation path search 2024-09-26 17:59:08 +08:00
novohool
9a14296e02 Update settings.example.py 2024-09-26 17:00:51 +08:00
pycook
f638b52759 fix(api): change records of attribute values for date and datetime 2024-09-25 19:37:08 +08:00
pycook
78da728105 fix(api): search for multiple CIType 2024-09-24 17:46:27 +08:00
pycook
eb69029a51 fix(api): ci relations search 2024-09-23 19:46:43 +08:00
Leo Song
07a097eba2 Merge pull request #619 from veops/dev_ui_240920
feat: update computed attr tip
2024-09-20 15:36:55 +08:00
songlh
e843e3eac9 feat: update computed attr tip 2024-09-20 15:36:19 +08:00
Leo Song
7308cfa6c2 Merge pull request #617 from veops/dev_ui_240914
dev_ui_240914
2024-09-14 17:28:42 +08:00
songlh
64ea4fb21f fix(ui): operation history search expand error 2024-09-14 17:27:57 +08:00
songlh
e15cefaa38 fix(ui): employeeTreeSelect display error 2024-09-14 17:26:33 +08:00
pycook
f32339b969 Merge pull request #616 from thexqn/optimize_history
feat: Add show_attr value column to operation history table
2024-09-14 11:55:01 +08:00
thexqn
131d213a73 优化CITypeCache的调用方式 2024-09-14 11:30:45 +08:00
thexqn
ff98777689 feat(cmdb): 添加操作历史表的唯一值列 (Add unique value column to operation history table) 2024-09-14 01:13:07 +08:00
thexqn
383d4c88ed feat: Add unique value column to operation history table 2024-09-13 23:44:40 +08:00
Leo Song
bb7157e292 Merge pull request #615 from veops/dev_ui_240913
feat(ui): add employeeTreeSelect otherOptions prop
2024-09-13 18:36:48 +08:00
songlh
b1a82f1a67 feat(ui): add employeeTreeSelect otherOptions prop 2024-09-13 18:36:24 +08:00
pycook
de86ea3852 fix(api): remote ip for login log 2024-09-10 11:41:35 +08:00
pycook
bf05ea240e feat(api): acl supports channel 2024-09-09 15:28:20 +08:00
Leo Song
8ec0d619d7 Merge pull request #613 from veops/dev_ui_240909
feat(ui): add SplitPane calcBasedParent prop
2024-09-09 10:45:27 +08:00
songlh
61f8c463bc feat(ui): add SplitPane calcBasedParent prop 2024-09-09 10:44:58 +08:00
Leo Song
9b4dc3e43b Merge pull request #611 from veops/dev_ui_240903
feat: update icon select
2024-09-03 16:41:18 +08:00
songlh
9e69be8256 feat: update icon select 2024-09-03 16:40:46 +08:00
65 changed files with 7327 additions and 933 deletions

1
.gitignore vendored
View File

@@ -78,3 +78,4 @@ cmdb-ui/npm-debug.log*
cmdb-ui/yarn-debug.log*
cmdb-ui/yarn-error.log*
cmdb-ui/package-lock.json
start.sh

View File

@@ -68,6 +68,7 @@ pycryptodomex = ">=3.19.0"
lz4 = ">=4.3.2"
python-magic = "==0.4.27"
jsonpath = "==0.82.2"
networkx = ">=3.1"
[dev-packages]
# Testing

View File

@@ -709,13 +709,18 @@ class CIManager(object):
elif fields:
_res = []
for d in res:
if isinstance(fields, dict) and d.get("_type") not in fields:
_res.append(d)
continue
_d = dict()
_d["_id"], _d["_type"] = d.get("_id"), d.get("_type")
_d["ci_type"] = d.get("ci_type")
if unique_required:
_d[d.get('unique')] = d.get(d.get('unique'))
for field in fields + ['ci_type_alias', 'unique', 'unique_alias']:
_fields = list(fields.get(_d['_type']) or [] if isinstance(fields, dict) else fields)
for field in _fields + ['ci_type_alias', 'unique', 'unique_alias']:
_d[field] = d.get(field)
_res.append(_d)
return _res
@@ -732,9 +737,8 @@ class CIManager(object):
from api.lib.cmdb.search.ci.db.query_sql import QUERY_CIS_BY_IDS
from api.lib.cmdb.search.ci.db.query_sql import QUERY_CIS_BY_VALUE_TABLE
if not fields:
filter_fields_sql = ""
else:
filter_fields_sql = ""
if fields and isinstance(fields, list):
_fields = list()
for field in fields:
attr = AttributeCache.get(field)
@@ -776,6 +780,10 @@ class CIManager(object):
ci_set.add(ci_id)
res[ci2pos[ci_id]] = ci_dict
if isinstance(fields, dict) and fields.get(type_id):
if attr_name not in fields[type_id]:
continue
if ret_key == RetKey.NAME:
attr_key = attr_name
elif ret_key == RetKey.ALIAS:
@@ -813,7 +821,7 @@ class CIManager(object):
if not ci_ids:
return []
fields = [] if fields is None or not isinstance(fields, list) else fields
fields = [] if not fields else fields
ci_id_tuple = tuple(map(int, ci_ids))
res = cls._get_cis_from_cache(ci_id_tuple, ret_key, fields, unique_required, excludes=excludes)

View File

@@ -3,6 +3,7 @@
from collections import defaultdict
import copy
import networkx as nx
import toposort
from flask import abort
from flask import current_app
@@ -845,6 +846,29 @@ class CITypeRelationManager(object):
return ids
@staticmethod
def find_path(source_type_id, target_type_ids):
source_type_id = int(source_type_id)
target_type_ids = map(int, target_type_ids)
graph = nx.DiGraph()
def get_children(_id):
children = CITypeRelation.get_by(parent_id=_id, to_dict=False)
for i in children:
if i.child_id != _id:
graph.add_edge(i.parent_id, i.child_id)
get_children(i.child_id)
get_children(source_type_id)
paths = list(nx.all_simple_paths(graph, source_type_id, target_type_ids))
del graph
return paths
@staticmethod
def _wrap_relation_type_dict(type_id, relation_inst):
ci_type_dict = CITypeCache.get(type_id).to_dict()

View File

@@ -10,6 +10,7 @@ from api.extensions import db
from api.lib.cmdb.cache import AttributeCache
from api.lib.cmdb.cache import RelationTypeCache
from api.lib.cmdb.const import OperateType
from api.lib.cmdb.cache import CITypeCache
from api.lib.cmdb.perms import CIFilterPermsCRUD
from api.lib.cmdb.resp_format import ErrFormat
from api.lib.perm.acl.cache import UserCache
@@ -22,6 +23,7 @@ from api.models.cmdb import CITypeHistory
from api.models.cmdb import CITypeTrigger
from api.models.cmdb import CITypeUniqueConstraint
from api.models.cmdb import OperationRecord
from api.lib.cmdb.utils import TableMap
class AttributeHistoryManger(object):
@@ -59,8 +61,23 @@ class AttributeHistoryManger(object):
total = len(records)
res = {}
show_attr_set = {}
show_attr_cache = {}
for record in records:
record_id = record.OperationRecord.id
type_id = record.OperationRecord.type_id
ci_id = record.AttributeHistory.ci_id
show_attr_set[ci_id] = None
show_attr = show_attr_cache.setdefault(
type_id,
AttributeCache.get(
CITypeCache.get(type_id).show_id or CITypeCache.get(type_id).unique_id) if CITypeCache.get(type_id) else None
)
if show_attr:
attr_table = TableMap(attr=show_attr).table
attr_record = attr_table.get_by(attr_id=show_attr.id, ci_id=ci_id, first=True, to_dict=False)
show_attr_set[ci_id] = attr_record.value if attr_record else None
attr_hist = record.AttributeHistory.to_dict()
attr_hist['attr'] = AttributeCache.get(attr_hist['attr_id'])
if attr_hist['attr']:
@@ -76,6 +93,7 @@ class AttributeHistoryManger(object):
if record_id not in res:
record_dict = record.OperationRecord.to_dict()
record_dict['show_attr_value'] = show_attr_set.get(ci_id)
record_dict["user"] = UserCache.get(record_dict.get("uid"))
if record_dict["user"]:
record_dict['user'] = record_dict['user'].nickname

View File

@@ -154,3 +154,5 @@ class ErrFormat(CommonErrFormat):
topology_group_exists = _l("Topology group {} already exists") # 拓扑视图分组 {} 已经存在
# 因为该分组下定义了拓扑视图,不能删除
topo_view_exists_cannot_delete_group = _l("The group cannot be deleted because the topology view already exists")
relation_path_search_src_target_required = _l("Both the source model and the target model must be selected")

View File

@@ -4,8 +4,8 @@
from __future__ import unicode_literals
import copy
import six
import time
from flask import current_app
from flask_login import current_user
from jinja2 import Template
@@ -66,6 +66,7 @@ class Search(object):
self.use_id_filter = use_id_filter
self.use_ci_filter = use_ci_filter
self.only_ids = only_ids
self.multi_type_has_ci_filter = False
self.valid_type_names = []
self.type2filter_perms = dict()
@@ -104,35 +105,56 @@ class Search(object):
else:
raise SearchError(ErrFormat.attribute_not_found.format(key))
def _type_query_handler(self, v, queries):
def _type_query_handler(self, v, queries, is_sub=False):
new_v = v[1:-1].split(";") if v.startswith("(") and v.endswith(")") else [v]
type_num = len(new_v)
type_id_list = []
for _v in new_v:
ci_type = CITypeCache.get(_v)
if len(new_v) == 1 and not self.sort and ci_type and ci_type.default_order_attr:
if type_num == 1 and not self.sort and ci_type and ci_type.default_order_attr:
self.sort = ci_type.default_order_attr
if ci_type is not None:
if self.valid_type_names == "ALL" or ci_type.name in self.valid_type_names:
self.type_id_list.append(str(ci_type.id))
if ci_type.id in self.type2filter_perms:
if not is_sub:
self.type_id_list.append(str(ci_type.id))
type_id_list.append(str(ci_type.id))
if ci_type.id in self.type2filter_perms and not is_sub:
ci_filter = self.type2filter_perms[ci_type.id].get('ci_filter')
if ci_filter and self.use_ci_filter and not self.use_id_filter:
sub = []
ci_filter = Template(ci_filter).render(user=current_user)
for i in ci_filter.split(','):
if i.startswith("~") and not sub:
queries.append(i)
if type_num == 1:
if i.startswith("~") and not sub:
queries.append(i)
else:
sub.append(i)
else:
sub.append(i)
if sub:
queries.append(dict(operator="&", queries=sub))
if type_num == 1:
queries.append(dict(operator="&", queries=sub))
else:
if str(ci_type.id) in self.type_id_list:
self.type_id_list.remove(str(ci_type.id))
type_id_list.remove(str(ci_type.id))
sub.extend([i for i in queries[1:] if isinstance(i, six.string_types)])
sub.insert(0, "_type:{}".format(ci_type.id))
queries.append(dict(operator="|", queries=sub))
self.multi_type_has_ci_filter = True
if self.type2filter_perms[ci_type.id].get('attr_filter'):
if not self.fl:
self.fl = set(self.type2filter_perms[ci_type.id]['attr_filter'])
if type_num == 1:
if not self.fl:
self.fl = set(self.type2filter_perms[ci_type.id]['attr_filter'])
else:
self.fl = set(self.fl) & set(self.type2filter_perms[ci_type.id]['attr_filter'])
else:
self.fl = set(self.fl) & set(self.type2filter_perms[ci_type.id]['attr_filter'])
self.fl = self.fl or {}
if not self.fl or isinstance(self.fl, dict):
self.fl[ci_type.id] = set(self.type2filter_perms[ci_type.id]['attr_filter'])
if self.type2filter_perms[ci_type.id].get('id_filter') and self.use_id_filter:
@@ -146,13 +168,17 @@ class Search(object):
else:
raise SearchError(ErrFormat.ci_type_not_found2.format(_v))
if self.type_id_list:
type_ids = ",".join(self.type_id_list)
if type_num != len(self.type_id_list) and queries and queries[0].startswith('_type') and not is_sub:
queries[0] = "_type:({})".format(";".join(self.type_id_list))
if type_id_list:
type_ids = ",".join(type_id_list)
_query_sql = QUERY_CI_BY_TYPE.format(type_ids)
if self.only_type_query:
if self.only_type_query or self.multi_type_has_ci_filter:
return _query_sql
else:
return ""
elif type_num > 1: # there must be instance-level access control
return "select c_cis.id as ci_id from c_cis where c_cis.id=0"
return ""
@staticmethod
@@ -229,7 +255,7 @@ class Search(object):
return ret_sql.format(query_sql, "ORDER BY B.ci_id {1} LIMIT {0:d}, {2};".format(
(self.page - 1) * self.count, sort_type, self.count))
elif self.type_id_list:
elif self.type_id_list and not self.multi_type_has_ci_filter:
self.query_sql = "SELECT B.ci_id FROM ({0}) AS B {1}".format(
query_sql,
"INNER JOIN c_cis on c_cis.id=B.ci_id WHERE c_cis.type_id IN ({0}) ".format(
@@ -254,7 +280,7 @@ class Search(object):
def __sort_by_type(self, sort_type, query_sql):
ret_sql = "SELECT SQL_CALC_FOUND_ROWS DISTINCT B.ci_id FROM ({0}) AS B {1}"
if self.type_id_list:
if self.type_id_list and not self.multi_type_has_ci_filter:
self.query_sql = "SELECT B.ci_id FROM ({0}) AS B {1}".format(
query_sql,
"INNER JOIN c_cis on c_cis.id=B.ci_id WHERE c_cis.type_id IN ({0}) ".format(
@@ -287,7 +313,7 @@ class Search(object):
WHERE {1}.attr_id = {3}""".format("ALIAS", table_name, query_sql, attr_id)
new_table = _v_query_sql
if self.only_type_query or not self.type_id_list:
if self.only_type_query or not self.type_id_list or self.multi_type_has_ci_filter:
return ("SELECT SQL_CALC_FOUND_ROWS DISTINCT C.ci_id FROM ({0}) AS C ORDER BY C.value {2} "
"LIMIT {1:d}, {3};".format(new_table, (self.page - 1) * self.count, sort_type, self.count))
@@ -325,7 +351,9 @@ class Search(object):
INNER JOIN ({2}) as {3} USING(ci_id)""".format(query_sql, alias, _query_sql, alias + "A")
elif operator == "|" or operator == "|~":
query_sql = "SELECT * FROM ({0}) as {1} UNION ALL ({2})".format(query_sql, alias, _query_sql)
query_sql = "SELECT * FROM ({0}) as {1} UNION ALL SELECT * FROM ({2}) as {3}".format(query_sql, alias,
_query_sql,
alias + "A")
elif operator == "~":
query_sql = """SELECT * FROM ({0}) as {1} LEFT JOIN ({2}) as {3} USING(ci_id)
@@ -430,14 +458,14 @@ class Search(object):
return result
def __query_by_attr(self, q, queries, alias):
def __query_by_attr(self, q, queries, alias, is_sub=False):
k = q.split(":")[0].strip()
v = "\:".join(q.split(":")[1:]).strip()
v = v.replace("'", "\\'")
v = v.replace('"', '\\"')
field, field_type, operator, attr = self._attr_name_proc(k)
if field == "_type":
_query_sql = self._type_query_handler(v, queries)
_query_sql = self._type_query_handler(v, queries, is_sub)
elif field == "_id":
_query_sql = self._id_query_handler(v)
@@ -484,19 +512,20 @@ class Search(object):
return alias, _query_sql, operator
def __query_build_by_field(self, queries, is_first=True, only_type_query_special=True, alias='A', operator='&'):
def __query_build_by_field(self, queries, is_first=True, only_type_query_special=True, alias='A', operator='&',
is_sub=False):
query_sql = ""
for q in queries:
_query_sql = ""
if isinstance(q, dict):
alias, _query_sql, operator = self.__query_build_by_field(q['queries'], True, True, alias)
current_app.logger.info(_query_sql)
current_app.logger.info((operator, is_first, alias))
alias, _query_sql, operator = self.__query_build_by_field(q['queries'], True, True, alias, is_sub=True)
# current_app.logger.info(_query_sql)
# current_app.logger.info((operator, is_first, alias))
operator = q['operator']
elif ":" in q and not q.startswith("*"):
alias, _query_sql, operator = self.__query_by_attr(q, queries, alias)
alias, _query_sql, operator = self.__query_by_attr(q, queries, alias, is_sub)
elif q == "*":
continue
elif q:
@@ -547,7 +576,6 @@ class Search(object):
queries = handle_arg_list(self.orig_query)
queries = self._extra_handle_query_expr(queries)
queries = self.__confirm_type_first(queries)
current_app.logger.debug(queries)
_, query_sql, _ = self.__query_build_by_field(queries)
@@ -585,13 +613,16 @@ class Search(object):
return facet_result
def _fl_build(self):
_fl = list()
for f in self.fl:
k, _, _, _ = self._attr_name_proc(f)
if k:
_fl.append(k)
if isinstance(self.fl, list):
_fl = list()
for f in self.fl:
k, _, _, _ = self._attr_name_proc(f)
if k:
_fl.append(k)
return _fl
return _fl
else:
return self.fl
def search(self):
numfound, ci_ids = self._query_build_raw()
@@ -610,6 +641,8 @@ class Search(object):
if ci_ids:
response = CIManager.get_cis_by_ids(ci_ids, ret_key=self.ret_key, fields=_fl, excludes=self.excludes)
for res in response:
if not res:
continue
ci_type = res.get("ci_type")
if ci_type not in counter.keys():
counter[ci_type] = 0

View File

@@ -1,8 +1,11 @@
# -*- coding:utf-8 -*-
import json
import sys
from collections import Counter
from collections import defaultdict
import copy
import json
import networkx as nx
import sys
from flask import abort
from flask import current_app
from flask_login import current_user
@@ -13,6 +16,7 @@ from api.lib.cmdb.cache import CITypeCache
from api.lib.cmdb.ci import CIRelationManager
from api.lib.cmdb.ci_type import CITypeRelationManager
from api.lib.cmdb.const import ConstraintEnum
from api.lib.cmdb.const import PermEnum
from api.lib.cmdb.const import REDIS_PREFIX_CI_RELATION
from api.lib.cmdb.const import REDIS_PREFIX_CI_RELATION2
from api.lib.cmdb.const import ResourceTypeEnum
@@ -25,10 +29,12 @@ from api.lib.cmdb.utils import ValueTypeMap
from api.lib.perm.acl.acl import ACLManager
from api.lib.perm.acl.acl import is_app_admin
from api.models.cmdb import CI
from api.models.cmdb import CITypeRelation
from api.models.cmdb import RelationType
class Search(object):
def __init__(self, root_id,
def __init__(self, root_id=None,
level=None,
query=None,
fl=None,
@@ -419,3 +425,169 @@ class Search(object):
level_ids = _level_ids
return result
@staticmethod
def _get_src_ids(src):
q = src.get('q') or ''
if not q.startswith('_type:'):
q = "_type:{},{}".format(src['type_id'], q)
return SearchFromDB(q, use_ci_filter=True, only_ids=True, count=100000).search()
@staticmethod
def _filter_target_ids(target_ids, type_ids, q):
if not q.startswith('_type:'):
q = "_type:({}),{}".format(";".join(map(str, type_ids)), q)
ci_ids = SearchFromDB(q, ci_ids=target_ids, use_ci_filter=True, only_ids=True, count=100000).search()
cis = CI.get_by(fl=['id', 'type_id'], only_query=True).filter(CI.id.in_(ci_ids))
return [(str(i.id), i.type_id) for i in cis]
@staticmethod
def _path2level(src_type_id, target_type_ids, path):
if not src_type_id or not target_type_ids:
return abort(400, ErrFormat.relation_path_search_src_target_required)
graph = nx.DiGraph()
graph.add_edges_from([(n, _path[idx + 1]) for _path in path for idx, n in enumerate(_path[:-1])])
relation_types = defaultdict(dict)
level2type = defaultdict(set)
type2show_key = dict()
for _path in path:
for idx, node in enumerate(_path[1:]):
level2type[idx + 1].add(node)
src = CITypeCache.get(_path[idx])
target = CITypeCache.get(node)
relation_type = RelationType.get_by(only_query=True).join(
CITypeRelation, CITypeRelation.relation_type_id == RelationType.id).filter(
CITypeRelation.parent_id == src.id).filter(CITypeRelation.child_id == target.id).first()
relation_types[src.alias].update({target.alias: relation_type.name})
if src.id not in type2show_key:
type2show_key[src.id] = AttributeCache.get(src.show_id or src.unique_id).name
if target.id not in type2show_key:
type2show_key[target.id] = AttributeCache.get(target.show_id or target.unique_id).name
nodes = graph.nodes()
return level2type, list(nodes), relation_types, type2show_key
def _build_graph(self, source_ids, source_type_id, level2type, target_type_ids, acl):
type2filter_perms = dict()
if not self.is_app_admin:
res2 = acl.get_resources(ResourceTypeEnum.CI_FILTER)
if res2:
type2filter_perms = CIFilterPermsCRUD().get_by_ids(list(map(int, [i['name'] for i in res2])))
target_type_ids = set(target_type_ids)
graph = nx.DiGraph()
target_ids = []
key = [(str(i), source_type_id) for i in source_ids]
graph.add_nodes_from(key)
for level in level2type:
filter_type_ids = level2type[level]
id_filter_limit = dict()
for _type_id in filter_type_ids:
if type2filter_perms.get(_type_id):
_id_filter_limit, _ = self._get_ci_filter(type2filter_perms[_type_id])
id_filter_limit.update(_id_filter_limit)
has_target = filter_type_ids & target_type_ids
res = [json.loads(x).items() for x in [i or '{}' for i in rd.get([i[0] for i in key],
REDIS_PREFIX_CI_RELATION) or []]]
_key = []
for idx, _id in enumerate(key):
valid_targets = [i for i in res[idx] if i[1] in filter_type_ids and
(not id_filter_limit or int(i[0]) in id_filter_limit)]
_key.extend(valid_targets)
graph.add_edges_from(zip([_id] * len(valid_targets), valid_targets))
if has_target:
target_ids.extend([j[0] for i in res for j in i if j[1] in target_type_ids])
key = copy.deepcopy(_key)
return graph, target_ids
@staticmethod
def _find_paths(graph, source_ids, source_type_id, target_ids, valid_path, max_depth=6):
paths = []
for source_id in source_ids:
_paths = nx.all_simple_paths(graph,
source=(source_id, source_type_id),
target=target_ids,
cutoff=max_depth)
for __path in _paths:
if tuple([i[1] for i in __path]) in valid_path:
paths.append([i[0] for i in __path])
return paths
@staticmethod
def _wrap_path_result(paths, types, valid_path, target_types, type2show_key):
ci_ids = [j for i in paths for j in i]
response, _, _, _, _, _ = SearchFromDB("_type:({})".format(";".join(map(str, types))),
use_ci_filter=False,
ci_ids=list(map(int, ci_ids)),
count=1000000).search()
id2ci = {str(i.get('_id')): i if i['_type'] in target_types else {
type2show_key[i['_type']]: i[type2show_key[i['_type']]],
"ci_type_alias": i["ci_type_alias"],
"_type": i["_type"],
} for i in response}
result = defaultdict(list)
counter = defaultdict(int)
for path in paths:
key = "-".join([id2ci.get(i, {}).get('ci_type_alias') or '' for i in path])
if tuple([id2ci.get(i, {}).get('_type') for i in path]) in valid_path:
counter[key] += 1
result[key].append(path)
return result, counter, id2ci
def search_by_path(self, source, target, path):
"""
:param source: {type_id: id, q: expr}
:param target: {type_ids: [id], q: expr}
:param path: [source_type_id, ..., target_type_id], use type id
:return:
"""
acl = ACLManager('cmdb')
if not self.is_app_admin:
res = {i['name'] for i in acl.get_resources(ResourceTypeEnum.CI_TYPE)}
for type_id in (source.get('type_id') and [source['type_id']] or []) + (target.get('type_ids') or []):
_type = CITypeCache.get(type_id)
if _type and _type.name not in res:
return abort(403, ErrFormat.no_permission.format(_type.alias, PermEnum.READ))
target['type_ids'] = [i[-1] for i in path]
level2type, types, relation_types, type2show_key = self._path2level(
source.get('type_id'), target.get('type_ids'), path)
if not level2type:
return [], {}, 0, self.page, 0, {}, {}
source_ids = self._get_src_ids(source)
graph, target_ids = self._build_graph(source_ids, source['type_id'], level2type, target['type_ids'], acl)
target_ids = self._filter_target_ids(target_ids, target['type_ids'], target.get('q') or '')
paths = self._find_paths(graph,
source_ids,
source['type_id'],
set(target_ids),
{tuple(i): 1 for i in path})
numfound = len(paths)
paths = paths[(self.page - 1) * self.count:self.page * self.count]
response, counter, id2ci = self._wrap_path_result(paths,
types,
{tuple(i): 1 for i in path},
set(target.get('type_ids') or []),
type2show_key)
return response, counter, len(paths), self.page, numfound, id2ci, relation_types, type2show_key

View File

@@ -97,6 +97,8 @@ class AttributeValueManager(object):
deserialize = ValueTypeMap.deserialize[value_type]
try:
v = deserialize(value)
if value_type in (ValueTypeEnum.DATE, ValueTypeEnum.DATETIME):
return str(v)
return v
except ValueDeserializeError as e:
return abort(400, ErrFormat.attribute_value_invalid2.format(alias, e))

View File

@@ -376,7 +376,7 @@ class AuditCRUD(object):
origin=origin, current=current, extra=extra, source=source.value)
@classmethod
def add_login_log(cls, username, is_ok, description, _id=None, logout_at=None):
def add_login_log(cls, username, is_ok, description, _id=None, logout_at=None, ip=None, browser=None):
if _id is not None:
existed = AuditLoginLog.get_by_id(_id)
if existed is not None:
@@ -387,8 +387,9 @@ class AuditCRUD(object):
is_ok=is_ok,
description=description,
logout_at=logout_at,
ip=request.headers.get('X-Real-IP') or request.remote_addr,
browser=request.headers.get('User-Agent'),
ip=(ip or request.headers.get('X-Forwarded-For') or
request.headers.get('X-Real-IP') or request.remote_addr or '').split(',')[0],
browser=browser or request.headers.get('User-Agent'),
channel=request.values.get('channel', 'web'),
)

View File

@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2024-08-20 13:47+0800\n"
"POT-Creation-Date: 2024-09-26 17:57+0800\n"
"PO-Revision-Date: 2023-12-25 20:21+0800\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: zh\n"
@@ -492,6 +492,10 @@ msgstr "拓扑视图分组 {} 已经存在"
msgid "The group cannot be deleted because the topology view already exists"
msgstr "因为该分组下定义了拓扑视图,不能删除"
#: api/lib/cmdb/resp_format.py:158
msgid "Both the source model and the target model must be selected"
msgstr "源模型和目标模型不能为空!"
#: api/lib/common_setting/resp_format.py:8
msgid "Company info already existed"
msgstr "公司信息已存在,无法创建!"

View File

@@ -1,7 +1,6 @@
# -*- coding:utf-8 -*-
import datetime
import jwt
import six
from flask import abort
@@ -17,10 +16,12 @@ from api.lib.decorator import args_required
from api.lib.decorator import args_validate
from api.lib.perm.acl.acl import ACLManager
from api.lib.perm.acl.audit import AuditCRUD
from api.lib.perm.acl.cache import AppCache
from api.lib.perm.acl.cache import RoleCache
from api.lib.perm.acl.cache import User
from api.lib.perm.acl.cache import UserCache
from api.lib.perm.acl.resp_format import ErrFormat
from api.lib.perm.acl.role import RoleRelationCRUD
from api.lib.perm.auth import auth_abandoned
from api.lib.perm.auth import auth_with_app_token
from api.models.acl import Role
@@ -124,10 +125,17 @@ class AuthWithKeyView(APIView):
if not user.get('username'):
user['username'] = user.get('name')
return self.jsonify(user=user,
authenticated=authenticated,
rid=role and role.id,
can_proxy=can_proxy)
result = dict(user=user,
authenticated=authenticated,
rid=role and role.id,
can_proxy=can_proxy)
if request.values.get('need_parentRoles') in current_app.config.get('BOOL_TRUE'):
app_id = AppCache.get(request.values.get('app_id'))
parent_ids = RoleRelationCRUD.recursive_parent_ids(role and role.id, app_id and app_id.id)
result['user']['parentRoles'] = [RoleCache.get(rid).name for rid in set(parent_ids) if RoleCache.get(rid)]
return self.jsonify(result)
class AuthWithTokenView(APIView):
@@ -184,6 +192,8 @@ class LogoutView(APIView):
def post(self):
logout_user()
AuditCRUD.add_login_log(None, None, None, _id=session.get('LOGIN_ID'), logout_at=datetime.datetime.now())
AuditCRUD.add_login_log(None, None, None,
_id=session.get('LOGIN_ID') or request.values.get('LOGIN_ID'),
logout_at=datetime.datetime.now())
self.jsonify(code=200)

View File

@@ -11,6 +11,7 @@ from flask_login import current_user
from api.lib.decorator import args_required
from api.lib.decorator import args_validate
from api.lib.perm.acl.acl import ACLManager
from api.lib.perm.acl.acl import AuditCRUD
from api.lib.perm.acl.acl import role_required
from api.lib.perm.acl.cache import AppCache
from api.lib.perm.acl.cache import UserCache
@@ -48,6 +49,13 @@ class GetUserInfoView(APIView):
role=dict(permissions=user_info.get('parents')),
avatar=user_info.get('avatar'))
if request.values.get('channel'):
_id = AuditCRUD.add_login_log(name, True, ErrFormat.login_succeed,
ip=request.values.get('ip'),
browser=request.values.get('browser'))
session['LOGIN_ID'] = _id
result['LOGIN_ID'] = _id
current_app.logger.info("get user info for3: {}".format(result))
return self.jsonify(result=result)

View File

@@ -2,7 +2,6 @@
import time
from flask import abort
from flask import current_app
from flask import request
@@ -65,6 +64,42 @@ class CIRelationSearchView(APIView):
result=response)
class CIRelationSearchPathView(APIView):
url_prefix = ("/ci_relations/path/s", "/ci_relations/path/search")
@args_required("source", "target", "path")
def post(self):
"""@params: page: page number
page_size | count: page size
source: source CIType, e.g. {type_id: 1, q: `search expr`}
target: target CIType, e.g. {type_ids: [2], q: `search expr`}
path: Path from the Source CIType to the Target CIType, e.g. [1, ..., 2]
"""
page = get_page(request.values.get("page", 1))
count = get_page_size(request.values.get("count") or request.values.get("page_size"))
source = request.values.get("source")
target = request.values.get("target")
path = request.values.get("path")
s = Search(page=page, count=count)
try:
(response, counter, total, page, numfound, id2ci,
relation_types, type2show_key) = s.search_by_path(source, target, path)
except SearchError as e:
return abort(400, str(e))
return self.jsonify(numfound=numfound,
total=total,
page=page,
counter=counter,
paths=response,
id2ci=id2ci,
relation_types=relation_types,
type2show_key=type2show_key)
class CIRelationStatisticsView(APIView):
url_prefix = "/ci_relations/statistics"

View File

@@ -8,7 +8,6 @@ from api.lib.cmdb.ci_type import CITypeManager
from api.lib.cmdb.ci_type import CITypeRelationManager
from api.lib.cmdb.const import PermEnum
from api.lib.cmdb.const import ResourceTypeEnum
from api.lib.cmdb.const import RoleEnum
from api.lib.cmdb.preference import PreferenceManager
from api.lib.cmdb.resp_format import ErrFormat
from api.lib.common_setting.decorator import perms_role_required
@@ -17,7 +16,7 @@ from api.lib.decorator import args_required
from api.lib.perm.acl.acl import ACLManager
from api.lib.perm.acl.acl import has_perm_from_args
from api.lib.perm.acl.acl import is_app_admin
from api.lib.perm.acl.acl import role_required
from api.lib.utils import handle_arg_list
from api.resource import APIView
app_cli = CMDBApp()
@@ -42,6 +41,19 @@ class GetParentsView(APIView):
return self.jsonify(parents=CITypeRelationManager.get_parents(child_id))
class CITypeRelationPathView(APIView):
url_prefix = ("/ci_type_relations/path",)
@args_required("source_type_id", "target_type_ids")
def get(self):
source_type_id = request.values.get("source_type_id")
target_type_ids = handle_arg_list(request.values.get("target_type_ids"))
paths = CITypeRelationManager.find_path(source_type_id, target_type_ids)
return self.jsonify(paths=paths)
class CITypeRelationView(APIView):
url_prefix = ("/ci_type_relations", "/ci_type_relations/<int:parent_id>/<int:child_id>")

View File

@@ -56,3 +56,4 @@ colorama>=0.4.6
lz4>=4.3.2
python-magic==0.4.27
jsonpath==0.82.2
networkx>=3.1

View File

@@ -39,9 +39,9 @@ SQLALCHEMY_ENGINE_OPTIONS = {
# # cache
CACHE_TYPE = 'redis'
CACHE_REDIS_HOST = '127.0.0.1'
CACHE_REDIS_PORT = 6379
CACHE_REDIS_PASSWORD = ''
CACHE_REDIS_HOST = env.str('CACHE_REDIS_HOST', default='redis')
CACHE_REDIS_PORT = env.str('CACHE_REDIS_PORT', default='6379')
CACHE_REDIS_PASSWORD = env.str('CACHE_REDIS_PASSWORD', default='')
CACHE_KEY_PREFIX = 'CMDB::'
CACHE_DEFAULT_TIMEOUT = 3000

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
@font-face {
font-family: "iconfont"; /* Project id 3857903 */
src: url('iconfont.woff2?t=1725331691589') format('woff2'),
url('iconfont.woff?t=1725331691589') format('woff'),
url('iconfont.ttf?t=1725331691589') format('truetype');
src: url('iconfont.woff2?t=1729157759723') format('woff2'),
url('iconfont.woff?t=1729157759723') format('woff'),
url('iconfont.ttf?t=1729157759723') format('truetype');
}
.iconfont {
@@ -13,6 +13,206 @@
-moz-osx-font-smoothing: grayscale;
}
.oneterm-mysql:before {
content: "\e9e8";
}
.oneterm-redis:before {
content: "\e9e7";
}
.veops-sign_out:before {
content: "\e9e6";
}
.veops-company:before {
content: "\e9e5";
}
.veops-emails:before {
content: "\e9e4";
}
.veops-switch:before {
content: "\e9e3";
}
.qiyeweixin:before {
content: "\e9e2";
}
.veops-progress:before {
content: "\e9e1";
}
.veops-completed:before {
content: "\e9e0";
}
.itsm-ticketTime:before {
content: "\e9df";
}
.veops-notification:before {
content: "\e9dc";
}
.a-veops-account1:before {
content: "\e9dd";
}
.veops-personal:before {
content: "\e9de";
}
.itsm-customer_satisfaction2:before {
content: "\e9da";
}
.itsm-over2:before {
content: "\e9db";
}
.veops-search1:before {
content: "\e9d9";
}
.itsm-customer_satisfaction:before {
content: "\e9d8";
}
.itsm-over:before {
content: "\e9d7";
}
.itsm-request:before {
content: "\e9d6";
}
.itsm-release:before {
content: "\e9d5";
}
.veops-link:before {
content: "\e9d4";
}
.oneterm-command_record:before {
content: "\e9d3";
}
.ai-question:before {
content: "\e9d2";
}
.ai-sending:before {
content: "\e9d1";
}
.ai-dialogue:before {
content: "\e9d0";
}
.ai-report2:before {
content: "\e9cf";
}
.ai-delete:before {
content: "\e9cd";
}
.caise-knowledge:before {
content: "\e9ce";
}
.ai-article:before {
content: "\e9cc";
}
.ai-model_setup1:before {
content: "\e9cb";
}
.ai-report:before {
content: "\e9ca";
}
.ai-customer_service:before {
content: "\e9c9";
}
.oneterm-connect1:before {
content: "\e9c6";
}
.oneterm-session1:before {
content: "\e9c7";
}
.oneterm-assets:before {
content: "\e9c8";
}
.a-oneterm-ssh1:before {
content: "\e9c3";
}
.a-oneterm-ssh2:before {
content: "\e9c4";
}
.oneterm-rdp:before {
content: "\e9c5";
}
.caise-websphere:before {
content: "\e9c2";
}
.caise-vps:before {
content: "\e9c1";
}
.caise-F5:before {
content: "\e9c0";
}
.caise-HAProxy:before {
content: "\e9bf";
}
.caise-JBoss:before {
content: "\e9be";
}
.caise-dongfangtong:before {
content: "\e9bd";
}
.caise-kafka:before {
content: "\e9b7";
}
.caise-weblogic:before {
content: "\e9b8";
}
.caise-TDSQL:before {
content: "\e9b9";
}
.caise-kingbase:before {
content: "\e9ba";
}
.caise-dameng:before {
content: "\e9bb";
}
.caise-TIDB:before {
content: "\e9bc";
}
.veops-expand:before {
content: "\e9b6";
}

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,356 @@
"css_prefix_text": "",
"description": "",
"glyphs": [
{
"icon_id": "42155223",
"name": "oneterm-mysql",
"font_class": "oneterm-mysql",
"unicode": "e9e8",
"unicode_decimal": 59880
},
{
"icon_id": "42155225",
"name": "oneterm-redis",
"font_class": "oneterm-redis",
"unicode": "e9e7",
"unicode_decimal": 59879
},
{
"icon_id": "42154436",
"name": "veops-sign_out",
"font_class": "veops-sign_out",
"unicode": "e9e6",
"unicode_decimal": 59878
},
{
"icon_id": "42154310",
"name": "veops-company",
"font_class": "veops-company",
"unicode": "e9e5",
"unicode_decimal": 59877
},
{
"icon_id": "42154325",
"name": "veops-emails",
"font_class": "veops-emails",
"unicode": "e9e4",
"unicode_decimal": 59876
},
{
"icon_id": "42154350",
"name": "veops-switch",
"font_class": "veops-switch",
"unicode": "e9e3",
"unicode_decimal": 59875
},
{
"icon_id": "42154370",
"name": "veops-qiyeweixin",
"font_class": "qiyeweixin",
"unicode": "e9e2",
"unicode_decimal": 59874
},
{
"icon_id": "42134185",
"name": "veops-progress",
"font_class": "veops-progress",
"unicode": "e9e1",
"unicode_decimal": 59873
},
{
"icon_id": "42134110",
"name": "veops-completed",
"font_class": "veops-completed",
"unicode": "e9e0",
"unicode_decimal": 59872
},
{
"icon_id": "42133882",
"name": "itsm-ticketTime",
"font_class": "itsm-ticketTime",
"unicode": "e9df",
"unicode_decimal": 59871
},
{
"icon_id": "42122869",
"name": "veops-notification",
"font_class": "veops-notification",
"unicode": "e9dc",
"unicode_decimal": 59868
},
{
"icon_id": "42122868",
"name": "veops-account_password",
"font_class": "a-veops-account1",
"unicode": "e9dd",
"unicode_decimal": 59869
},
{
"icon_id": "42122861",
"name": "veops-personal",
"font_class": "veops-personal",
"unicode": "e9de",
"unicode_decimal": 59870
},
{
"icon_id": "42101103",
"name": "itsm-evaluation2",
"font_class": "itsm-customer_satisfaction2",
"unicode": "e9da",
"unicode_decimal": 59866
},
{
"icon_id": "42101098",
"name": "itsm-over2",
"font_class": "itsm-over2",
"unicode": "e9db",
"unicode_decimal": 59867
},
{
"icon_id": "42065574",
"name": "veops-search",
"font_class": "veops-search1",
"unicode": "e9d9",
"unicode_decimal": 59865
},
{
"icon_id": "42063479",
"name": "itsm-evaluation",
"font_class": "itsm-customer_satisfaction",
"unicode": "e9d8",
"unicode_decimal": 59864
},
{
"icon_id": "42062436",
"name": "itsm-over",
"font_class": "itsm-over",
"unicode": "e9d7",
"unicode_decimal": 59863
},
{
"icon_id": "42050642",
"name": "itsm-requirement",
"font_class": "itsm-request",
"unicode": "e9d6",
"unicode_decimal": 59862
},
{
"icon_id": "42050622",
"name": "itsm-release",
"font_class": "itsm-release",
"unicode": "e9d5",
"unicode_decimal": 59861
},
{
"icon_id": "41903314",
"name": "veops-link",
"font_class": "veops-link",
"unicode": "e9d4",
"unicode_decimal": 59860
},
{
"icon_id": "41876664",
"name": "oneterm-command_record",
"font_class": "oneterm-command_record",
"unicode": "e9d3",
"unicode_decimal": 59859
},
{
"icon_id": "41859436",
"name": "ai-question",
"font_class": "ai-question",
"unicode": "e9d2",
"unicode_decimal": 59858
},
{
"icon_id": "41859414",
"name": "ai-sending",
"font_class": "ai-sending",
"unicode": "e9d1",
"unicode_decimal": 59857
},
{
"icon_id": "41859374",
"name": "ai-dialogue",
"font_class": "ai-dialogue",
"unicode": "e9d0",
"unicode_decimal": 59856
},
{
"icon_id": "41859191",
"name": "ai-report2",
"font_class": "ai-report2",
"unicode": "e9cf",
"unicode_decimal": 59855
},
{
"icon_id": "41858720",
"name": "ai-delete",
"font_class": "ai-delete",
"unicode": "e9cd",
"unicode_decimal": 59853
},
{
"icon_id": "41858484",
"name": "caise-knowledge",
"font_class": "caise-knowledge",
"unicode": "e9ce",
"unicode_decimal": 59854
},
{
"icon_id": "41833445",
"name": "ai-article",
"font_class": "ai-article",
"unicode": "e9cc",
"unicode_decimal": 59852
},
{
"icon_id": "41811974",
"name": "ai-model_setup (1)",
"font_class": "ai-model_setup1",
"unicode": "e9cb",
"unicode_decimal": 59851
},
{
"icon_id": "41811980",
"name": "ai-report",
"font_class": "ai-report",
"unicode": "e9ca",
"unicode_decimal": 59850
},
{
"icon_id": "41811915",
"name": "ai-customer_service",
"font_class": "ai-customer_service",
"unicode": "e9c9",
"unicode_decimal": 59849
},
{
"icon_id": "41735717",
"name": "oneterm-connect",
"font_class": "oneterm-connect1",
"unicode": "e9c6",
"unicode_decimal": 59846
},
{
"icon_id": "41735716",
"name": "oneterm-session",
"font_class": "oneterm-session1",
"unicode": "e9c7",
"unicode_decimal": 59847
},
{
"icon_id": "41735703",
"name": "oneterm-assets",
"font_class": "oneterm-assets",
"unicode": "e9c8",
"unicode_decimal": 59848
},
{
"icon_id": "41725683",
"name": "oneterm-RDP",
"font_class": "a-oneterm-ssh1",
"unicode": "e9c3",
"unicode_decimal": 59843
},
{
"icon_id": "41725684",
"name": "oneterm-SSH",
"font_class": "a-oneterm-ssh2",
"unicode": "e9c4",
"unicode_decimal": 59844
},
{
"icon_id": "41725685",
"name": "oneterm-VNC",
"font_class": "oneterm-rdp",
"unicode": "e9c5",
"unicode_decimal": 59845
},
{
"icon_id": "41724497",
"name": "caise-websphere",
"font_class": "caise-websphere",
"unicode": "e9c2",
"unicode_decimal": 59842
},
{
"icon_id": "41724575",
"name": "caise-vps",
"font_class": "caise-vps",
"unicode": "e9c1",
"unicode_decimal": 59841
},
{
"icon_id": "41724631",
"name": "caise-F5",
"font_class": "caise-F5",
"unicode": "e9c0",
"unicode_decimal": 59840
},
{
"icon_id": "41724653",
"name": "caise-HAProxy",
"font_class": "caise-HAProxy",
"unicode": "e9bf",
"unicode_decimal": 59839
},
{
"icon_id": "41722953",
"name": "caise-JBoss",
"font_class": "caise-JBoss",
"unicode": "e9be",
"unicode_decimal": 59838
},
{
"icon_id": "41722960",
"name": "caise-dongfangtong",
"font_class": "caise-dongfangtong",
"unicode": "e9bd",
"unicode_decimal": 59837
},
{
"icon_id": "41722681",
"name": "caise-kafka",
"font_class": "caise-kafka",
"unicode": "e9b7",
"unicode_decimal": 59831
},
{
"icon_id": "41722680",
"name": "caise-weblogic",
"font_class": "caise-weblogic",
"unicode": "e9b8",
"unicode_decimal": 59832
},
{
"icon_id": "41722679",
"name": "caise-TDSQL",
"font_class": "caise-TDSQL",
"unicode": "e9b9",
"unicode_decimal": 59833
},
{
"icon_id": "41722678",
"name": "caise-kingbase",
"font_class": "caise-kingbase",
"unicode": "e9ba",
"unicode_decimal": 59834
},
{
"icon_id": "41722677",
"name": "达梦",
"font_class": "caise-dameng",
"unicode": "e9bb",
"unicode_decimal": 59835
},
{
"icon_id": "41722675",
"name": "caise-TIDB",
"font_class": "caise-TIDB",
"unicode": "e9bc",
"unicode_decimal": 59836
},
{
"icon_id": "41681675",
"name": "veops-expand",

Binary file not shown.

View File

@@ -177,7 +177,7 @@ export const linearIconList = [
}]
}, {
value: 'icon-xianxing-application',
label: '应用',
label: '常用组件',
list: [{
value: 'icon-xianxing-yilianjie',
label: '已连接'
@@ -517,7 +517,7 @@ export const fillIconList = [
}]
}, {
value: 'icon-shidi-application',
label: '应用',
label: '常用组件',
list: [{
value: 'icon-shidi-yilianjie',
label: '已连接'
@@ -729,6 +729,18 @@ export const multicolorIconList = [
value: 'database',
label: '数据库',
list: [{
value: 'caise-TIDB',
label: 'TIDB'
}, {
value: 'caise-dameng',
label: '达梦'
}, {
value: 'caise-kingbase',
label: 'KingBase'
}, {
value: 'caise-TDSQL',
label: 'TDSQL'
}, {
value: 'caise-DB2',
label: 'DB2'
}, {
@@ -809,6 +821,9 @@ export const multicolorIconList = [
value: 'system',
label: '操作系统',
list: [{
value: 'ciase-aix',
label: 'aix'
}, {
value: 'caise-Windows',
label: 'Windows'
}, {
@@ -903,8 +918,38 @@ export const multicolorIconList = [
}]
}, {
value: 'caise-application',
label: '应用',
label: '常用组件',
list: [{
value: 'caise-websphere',
label: 'WebSphere'
}, {
value: 'caise-vps',
label: 'VPS'
}, {
value: 'caise-F5',
label: 'F5'
}, {
value: 'caise-HAProxy',
label: 'HAProxy'
}, {
value: 'caise-kafka',
label: 'kafka'
}, {
value: 'caise-dongfangtong',
label: '东方通'
}, {
value: 'cmdb-vcenter',
label: 'VCenter'
}, {
value: 'ops-KVM',
label: 'KVM'
}, {
value: 'caise-JBoss',
label: 'JBoss'
}, {
value: 'caise-weblogic',
label: 'WebLogic'
}, {
value: 'caise-disk_array',
label: '磁盘阵列'
}, {
@@ -928,9 +973,6 @@ export const multicolorIconList = [
}, {
value: 'caise_pool',
label: 'ip池'
}, {
value: 'ciase-aix',
label: 'aix'
}, {
value: 'caise-storage_volume1',
label: '存储卷'

View File

@@ -0,0 +1,24 @@
.cmdb-side-menu-search {
background-color: #FFFFFF !important;
cursor: auto !important;
:global {
.ant-input-affix-wrapper {
max-width: 170px !important;
width: 170px;
border-radius: 30px;
}
.ant-input {
box-shadow: none;
border: none;
background-color: #F7F8FA;
height: 30px;
line-height: 30px;
}
.ant-input-suffix {
right: 0px !important;
}
}
}

View File

@@ -9,6 +9,8 @@ import {
import { searchResourceType } from '@/modules/acl/api/resource'
import { roleHasPermissionToGrant } from '@/modules/acl/api/permission'
import CMDBGrant from '@/modules/cmdb/components/cmdbGrant'
import styles from './index.module.less'
import { mapActions } from 'vuex'
const { Item, SubMenu } = Menu
@@ -40,7 +42,8 @@ export default {
openKeys: [],
selectedKeys: [],
cachedOpenKeys: [],
resource_type: {}
resource_type: {},
currentAppRoute: ''
}
},
computed: {
@@ -64,6 +67,7 @@ export default {
searchResourceType({ page_size: 9999, app_id: 'cmdb' }).then(res => {
this.resource_type = { groups: res.groups, id2perms: res.id2perms }
})
this.currentAppRoute = this.$route?.matched?.[0]?.name || ''
this.updateMenu()
},
watch: {
@@ -75,12 +79,14 @@ export default {
this.openKeys = this.cachedOpenKeys
}
},
$route: function () {
$route: function (route) {
this.currentAppRoute = route?.matched?.[0]?.name
this.updateMenu()
},
},
inject: ['reload'],
methods: {
...mapActions(['UpdateCMDBSEarchValue']),
cancelAttributes(e, menu) {
const that = this
e.preventDefault()
@@ -286,6 +292,47 @@ export default {
this.$message.error(this.$t('noPermission'))
}
})
},
jumpCMDBSearch(value) {
this.UpdateCMDBSEarchValue(value)
if (this.$route.name !== 'cmdb_resource_search') {
this.$router.push({
name: 'cmdb_resource_search',
})
}
},
renderCMDBSearch() {
if (this.currentAppRoute !== 'cmdb' || this.collapsed) {
return null
}
return (
<Item class={styles['cmdb-side-menu-search']}>
<a-input
ref="cmdbSideMenuSearchInputRef"
class={styles['cmdb-side-menu-search-input']}
style={{
border: this.$route.name === 'cmdb_resource_search' ? 'solid 1px #B1C9FF' : ''
}}
placeholder={this.$t('cmdbSearch')}
onPressEnter={(e) => {
this.jumpCMDBSearch(e.target.value)
}}
>
<ops-icon
slot="suffix"
type="veops-search1"
onClick={() => {
const value = this.$refs?.cmdbSideMenuSearchInputRef?.$refs?.input?.value || ''
this.jumpCMDBSearch(value)
}}
/>
</a-input>
</Item>
)
}
},
@@ -313,6 +360,7 @@ export default {
// {...{ props, on: on }}
return (
<Menu class="ops-side-bar" selectedKeys={this.selectedKeys} {...{ props, on: on }}>
{this.renderCMDBSearch()}
{menuTree}
</Menu>
)

View File

@@ -1,183 +1,187 @@
<template>
<div ref="splitPane" class="split-pane" :class="direction + ' ' + appName" :style="{ flexDirection: direction }">
<div class="pane pane-one" ref="one" :style="lengthType + ':' + paneLengthValue1">
<slot name="one"></slot>
</div>
<div class="spliter-wrap">
<a-button
v-show="collapsable"
:icon="isExpanded ? 'left' : 'right'"
class="collapse-btn"
@click="handleExpand"
></a-button>
<div
class="pane-trigger"
@mousedown="handleMouseDown"
:style="{ backgroundColor: triggerColor, width: `${triggerLength}px` }"
></div>
</div>
<div class="pane pane-two" ref="two" :style="lengthType + ':' + paneLengthValue2">
<slot name="two"></slot>
</div>
</div>
</template>
<script>
export default {
name: 'SplitPane',
props: {
direction: {
type: String,
default: 'row',
},
min: {
type: Number,
default: 10,
},
max: {
type: Number,
default: 90,
},
paneLengthPixel: {
type: Number,
default: 220,
},
triggerLength: {
type: Number,
default: 8,
},
appName: {
type: String,
default: 'viewer',
},
collapsable: {
type: Boolean,
default: false,
},
triggerColor: {
type: String,
default: '#f7f8fa',
},
},
data() {
return {
triggerLeftOffset: 0, // 鼠标距滑动器左()侧偏移量
isExpanded: localStorage.getItem(`${this.appName}-isExpanded`)
? JSON.parse(localStorage.getItem(`${this.appName}-isExpanded`))
: false,
parentContainer: null,
}
},
computed: {
lengthType() {
return this.direction === 'row' ? 'width' : 'height'
},
minLengthType() {
return this.direction === 'row' ? 'minWidth' : 'minHeight'
},
paneLengthValue1() {
return `calc(${this.paneLengthPercent}% - ${this.triggerLength / 2 + 'px'})`
},
paneLengthValue2() {
const rest = 100 - this.paneLengthPercent
return `calc(${rest}% - ${this.triggerLength / 2 + 'px'})`
},
paneLengthPercent() {
const clientRectWidth = this.parentContainer
? this.parentContainer.clientWidth
: document.documentElement.getBoundingClientRect().width
return (this.paneLengthPixel / clientRectWidth) * 100
},
},
watch: {
isExpanded(newValue) {
if (newValue) {
document.querySelector(`.${this.appName} .pane-two`).style.display = 'none'
} else {
document.querySelector(`.${this.appName} .pane-two`).style.display = ''
}
},
},
mounted() {
const paneLengthPixel = localStorage.getItem(`${this.appName}-paneLengthPixel`)
if (paneLengthPixel) {
this.$emit('update:paneLengthPixel', Number(paneLengthPixel))
}
this.parentContainer = document.querySelector(`.${this.appName}`)
if (this.isExpanded) {
document.querySelector(`.${this.appName} .pane-two`).style.display = 'none'
} else {
document.querySelector(`.${this.appName} .pane-two`).style.display = ''
}
},
methods: {
// 按下滑动器
handleMouseDown(e) {
document.addEventListener('mousemove', this.handleMouseMove)
document.addEventListener('mouseup', this.handleMouseUp)
if (this.direction === 'row') {
this.triggerLeftOffset = e.pageX - e.srcElement.getBoundingClientRect().left
} else {
this.triggerLeftOffset = e.pageY - e.srcElement.getBoundingClientRect().top
}
},
// 按下滑动器后移动鼠标
handleMouseMove(e) {
this.isExpanded = false
this.$emit('expand', this.isExpanded)
const clientRect = this.$refs.splitPane.getBoundingClientRect()
let paneLengthPixel = 0
if (this.direction === 'row') {
const offset = e.pageX - clientRect.left - this.triggerLeftOffset + this.triggerLength / 2
paneLengthPixel = offset
} else {
const offset = e.pageY - clientRect.top - this.triggerLeftOffset + this.triggerLength / 2
paneLengthPixel = offset
}
if (paneLengthPixel < this.min) {
paneLengthPixel = this.min
}
if (paneLengthPixel > this.max) {
paneLengthPixel = this.max
}
this.$emit('update:paneLengthPixel', paneLengthPixel)
localStorage.setItem(`${this.appName}-paneLengthPixel`, paneLengthPixel)
},
// 松开滑动器
handleMouseUp() {
document.removeEventListener('mousemove', this.handleMouseMove)
},
handleExpand() {
this.isExpanded = !this.isExpanded
this.$emit('expand', this.isExpanded)
localStorage.setItem(`${this.appName}-isExpanded`, this.isExpanded)
},
},
}
</script>
<style scoped lang="less">
@import './index.less';
</style>
<template>
<div ref="splitPane" class="split-pane" :class="direction + ' ' + appName" :style="{ flexDirection: direction }">
<div class="pane pane-one" ref="one" :style="lengthType + ':' + paneLengthValue1">
<slot name="one"></slot>
</div>
<div class="spliter-wrap">
<a-button
v-show="collapsable"
:icon="isExpanded ? 'left' : 'right'"
class="collapse-btn"
@click="handleExpand"
></a-button>
<div
class="pane-trigger"
@mousedown="handleMouseDown"
:style="{ backgroundColor: triggerColor, width: `${triggerLength}px` }"
></div>
</div>
<div class="pane pane-two" ref="two" :style="lengthType + ':' + paneLengthValue2">
<slot name="two"></slot>
</div>
</div>
</template>
<script>
export default {
name: 'SplitPane',
props: {
direction: {
type: String,
default: 'row',
},
min: {
type: Number,
default: 10,
},
max: {
type: Number,
default: 90,
},
paneLengthPixel: {
type: Number,
default: 220,
},
triggerLength: {
type: Number,
default: 8,
},
appName: {
type: String,
default: 'viewer',
},
collapsable: {
type: Boolean,
default: false,
},
triggerColor: {
type: String,
default: '#f7f8fa',
},
calcBasedParent: {
type: Boolean,
defualt: false
}
},
data() {
return {
triggerLeftOffset: 0, // 鼠标距滑动器左()侧偏移量
isExpanded: localStorage.getItem(`${this.appName}-isExpanded`)
? JSON.parse(localStorage.getItem(`${this.appName}-isExpanded`))
: false,
parentContainer: null,
}
},
computed: {
lengthType() {
return this.direction === 'row' ? 'width' : 'height'
},
minLengthType() {
return this.direction === 'row' ? 'minWidth' : 'minHeight'
},
paneLengthValue1() {
return `calc(${this.paneLengthPercent}% - ${this.triggerLength / 2 + 'px'})`
},
paneLengthValue2() {
const rest = 100 - this.paneLengthPercent
return `calc(${rest}% - ${this.triggerLength / 2 + 'px'})`
},
paneLengthPercent() {
const clientRectWidth = this.parentContainer && this.calcBasedParent
? this.parentContainer.clientWidth
: document.documentElement.getBoundingClientRect().width
return (this.paneLengthPixel / clientRectWidth) * 100
},
},
watch: {
isExpanded(newValue) {
if (newValue) {
document.querySelector(`.${this.appName} .pane-two`).style.display = 'none'
} else {
document.querySelector(`.${this.appName} .pane-two`).style.display = ''
}
},
},
mounted() {
const paneLengthPixel = localStorage.getItem(`${this.appName}-paneLengthPixel`)
if (paneLengthPixel) {
this.$emit('update:paneLengthPixel', Number(paneLengthPixel))
}
this.parentContainer = document.querySelector(`.${this.appName}`)
if (this.isExpanded) {
document.querySelector(`.${this.appName} .pane-two`).style.display = 'none'
} else {
document.querySelector(`.${this.appName} .pane-two`).style.display = ''
}
},
methods: {
// 按下滑动器
handleMouseDown(e) {
document.addEventListener('mousemove', this.handleMouseMove)
document.addEventListener('mouseup', this.handleMouseUp)
if (this.direction === 'row') {
this.triggerLeftOffset = e.pageX - e.srcElement.getBoundingClientRect().left
} else {
this.triggerLeftOffset = e.pageY - e.srcElement.getBoundingClientRect().top
}
},
// 按下滑动器后移动鼠标
handleMouseMove(e) {
this.isExpanded = false
this.$emit('expand', this.isExpanded)
const clientRect = this.$refs.splitPane.getBoundingClientRect()
let paneLengthPixel = 0
if (this.direction === 'row') {
const offset = e.pageX - clientRect.left - this.triggerLeftOffset + this.triggerLength / 2
paneLengthPixel = offset
} else {
const offset = e.pageY - clientRect.top - this.triggerLeftOffset + this.triggerLength / 2
paneLengthPixel = offset
}
if (paneLengthPixel < this.min) {
paneLengthPixel = this.min
}
if (paneLengthPixel > this.max) {
paneLengthPixel = this.max
}
this.$emit('update:paneLengthPixel', paneLengthPixel)
localStorage.setItem(`${this.appName}-paneLengthPixel`, paneLengthPixel)
},
// 松开滑动器
handleMouseUp() {
document.removeEventListener('mousemove', this.handleMouseMove)
},
handleExpand() {
this.isExpanded = !this.isExpanded
this.$emit('expand', this.isExpanded)
localStorage.setItem(`${this.appName}-isExpanded`, this.isExpanded)
},
},
}
</script>
<style scoped lang="less">
@import './index.less';
</style>

View File

@@ -6,7 +6,8 @@
:paneLengthPixel.sync="paneLengthPixel"
:appName="appName"
:triggerColor="triggerColor"
:triggerLength="18"
:triggerLength="triggerLength"
:calcBasedParent="calcBasedParent"
>
<template #one>
<div class="two-column-layout-sidebar">
@@ -37,6 +38,14 @@ export default {
type: String,
default: '#f7f8fa',
},
triggerLength: {
type: Number,
default: 18
},
calcBasedParent: {
type: Boolean,
defualt: false
}
},
data() {
return {

View File

@@ -108,6 +108,7 @@ export default {
visual: 'Visual',
default: 'default',
tip: 'Tip',
cmdbSearch: 'Search',
pagination: {
total: '{range0}-{range1} of {total} items'
},

View File

@@ -108,6 +108,7 @@ export default {
visual: '虚拟',
default: '默认',
tip: '提示',
cmdbSearch: '搜索一下',
pagination: {
total: '当前展示 {range0}-{range1} 条数据, 共 {total} 条'
},

View File

@@ -81,3 +81,11 @@ export function searchCIRelationFull(params) {
params,
})
}
export function searchCIRelationPath(data) {
return axios({
url: `/v0.1/ci_relations/path/s`,
method: 'POST',
data,
})
}

View File

@@ -74,3 +74,11 @@ export function getCanEditByParentIdChildId(parent_id, child_id) {
method: 'GET'
})
}
export function getCITypeRelationPath(params) {
return axios({
url: `/v0.1/ci_type_relations/path`,
method: 'GET',
params
})
}

View File

@@ -13,7 +13,10 @@
v-decorator="['filename', { rules: [{ required: true, message: $t('cmdb.components.filenameInputTips') }] }]"
/>
</a-form-item>
<a-form-item :label="$t('cmdb.components.saveType')">
<a-form-item
v-if="showFileTypeSelect"
:label="$t('cmdb.components.saveType')"
>
<a-select
:placeholder="$t('cmdb.components.saveTypeTips')"
v-decorator="[
@@ -83,6 +86,10 @@ export default {
type: String,
default: 'default',
},
showFileTypeSelect: {
type: Boolean,
default: true
}
},
data() {
return {

View File

@@ -286,6 +286,7 @@ const cmdb_en = {
attrCode: 'Attr Code',
computedAttrTip1: 'Reference attributes follow jinja2 syntax',
computedAttrTip2: `Multi-valued attributes (lists) are rendered with [ ] included by default, if you want to remove it, the reference method is: """{{ attr_name | join(',') }}""" where commas are separators`,
computedAttrTip3: `Cannot refer to other computed attributes`,
example: 'Example',
attrFilterTip: `The third column of values allows you to select attributes of this model to cascade attributes`,
rule: 'Rule',
@@ -740,5 +741,22 @@ if __name__ == "__main__":
topoViewSearchPlaceholder: 'Please enter the node name.',
moreBtn: 'Show more({count})'
},
relationSearch: {
relationSearch: 'Relation Search',
sourceCIType: 'Source CIType',
sourceCITypeTip: 'Please input or select',
sourceCITYpeInput: 'Please input keywords',
targetCIType: 'Target CIType',
targetCITypeTip: 'Please input or select, multiple choices available',
pathSelect: 'Path Select',
pathSelectTip: 'Please select source CIType and target CIType first',
saveCondition: 'Save Condition',
conditionFilter: 'Condition Filter',
level: 'Level',
returnPath: 'Return Path',
conditionName: 'Condition Name',
path: 'Path',
expandCondition: 'Expand Condition',
}
}
export default cmdb_en

View File

@@ -286,6 +286,7 @@ const cmdb_zh = {
attrCode: '属性代码',
computedAttrTip1: '引用属性遵循jinja2语法',
computedAttrTip2: `多值属性(列表)默认呈现包括[ ], 如果要去掉, 引用方法为: """{{ attr_name | join(',') }}""" 其中逗号为分隔符`,
computedAttrTip3: `不能引用其他计算属性`,
example: '例如',
attrFilterTip: '第三列值可选择本模型的属性,来实现级联属性的功能',
rule: '规则',
@@ -739,5 +740,22 @@ if __name__ == "__main__":
topoViewSearchPlaceholder: '请输入节点名字',
moreBtn: '展示更多({count})'
},
relationSearch: {
relationSearch: '关系搜索',
sourceCIType: '源模型',
sourceCITypeTip: '请输入或选择',
sourceCITYpeInput: '请输入关键词',
targetCIType: '目标模型',
targetCITypeTip: '请输入或选择,可多选',
pathSelect: '路径选择',
pathSelectTip: '请先选择源模型和目标模型',
saveCondition: '保存条件',
conditionFilter: '条件过滤',
level: '层级',
returnPath: '返回路径',
conditionName: '条件命名',
path: '路径',
expandCondition: '展开条件',
}
}
export default cmdb_zh

View File

@@ -53,6 +53,7 @@ const genCmdbRoutes = async () => {
{
path: '/cmdb/resourcesearch',
name: 'cmdb_resource_search',
hidden: true,
meta: { title: 'cmdb.menu.ciSearch', icon: 'ops-cmdb-search', selectedIcon: 'ops-cmdb-search', keepAlive: false },
component: () => import('../views/resource_search_2/index.vue')
},

View File

@@ -11,7 +11,7 @@ export function sum(arr) {
})
}
const strLength = (fData) => {
export const strLength = (fData) => {
if (!fData) {
return 0

View File

@@ -398,6 +398,7 @@
<div v-show="isShowComputedArea" class="computed-attr-tip">
<div>1. {{ $t('cmdb.ciType.computedAttrTip1') }}</div>
<div>2. {{ $t('cmdb.ciType.computedAttrTip2') }}</div>
<div>3. {{ $t('cmdb.ciType.computedAttrTip3') }}</div>
</div>
<ComputedArea
showCalcComputed

View File

@@ -391,6 +391,7 @@
<div v-show="isShowComputedArea" class="computed-attr-tip">
<div>1. {{ $t('cmdb.ciType.computedAttrTip1') }}</div>
<div>2. {{ $t('cmdb.ciType.computedAttrTip2') }}</div>
<div>3. {{ $t('cmdb.ciType.computedAttrTip3') }}</div>
</div>
<ComputedArea ref="computedArea" v-if="isShowComputedArea" :canDefineComputed="canDefineComputed" />
</a-form-item>

View File

@@ -13,6 +13,7 @@
:paneLengthPixel.sync="paneLengthPixel"
appName="cmdb-ci-types"
:triggerLength="18"
calcBasedParent
>
<template #one>
<div class="ci-types-left">
@@ -1112,7 +1113,7 @@ export default {
}
.ci-types-left-content {
max-height: calc(100% - 45px);
height: calc(100% - 45px);
overflow: hidden;
margin-top: 10px;

View File

@@ -198,6 +198,7 @@
<div class="script-tip">
<div>1. {{ $t('cmdb.ciType.computedAttrTip1') }}</div>
<div>2. {{ $t('cmdb.ciType.computedAttrTip2') }}</div>
<div>3. {{ $t('cmdb.ciType.computedAttrTip3') }}</div>
</div>
<div class="all-attr-btn">

View File

@@ -1,5 +1,5 @@
<template>
<TwoColumnLayout appName="cmdb-adc">
<TwoColumnLayout appName="cmdb-adc" calcBasedParent>
<template #one>
<div class="cmdb-adc-group" v-for="group in ci_types_list" :key="group.id">
<p>

View File

@@ -45,6 +45,7 @@
</template>
</vxe-column>
<vxe-column field="type_id" width="100px" :title="$t('cmdb.ciType.ciType')"></vxe-column>
<vxe-column field="show_attr_value" width="100px" :title="$t('cmdb.ci.instance')"></vxe-column>
<vxe-column field="operate_type" width="89px" :title="$t('operation')">
<template #header="{ column }">
<span>{{ column.title }}</span>
@@ -314,7 +315,7 @@ export default {
}
},
mergeRowMethod({ row, _rowIndex, column, visibleData }) {
const fields = ['created_at', 'user', 'type_id']
const fields = ['created_at', 'user', 'type_id', 'show_attr_value']
const cellValue = row[column.property]
const created_at = row['created_at']
if (column.property === 'created_at') {
@@ -365,6 +366,22 @@ export default {
}
}
}
} else if (column.property === 'show_attr_value') {
if (cellValue && fields.includes(column.property)) {
const prevRow = visibleData[_rowIndex - 1]
let nextRow = visibleData[_rowIndex + 1]
if (prevRow && prevRow[column.property] === cellValue && prevRow['created_at'] === created_at) {
return { rowspan: 0, colspan: 0 }
} else {
let countRowspan = 1
while (nextRow && nextRow[column.property] === cellValue && nextRow['created_at'] === created_at) {
nextRow = visibleData[++countRowspan + _rowIndex]
}
if (countRowspan > 1) {
return { rowspan: countRowspan, colspan: 1 }
}
}
}
}
},
filterUser() {

View File

@@ -85,7 +85,7 @@
@change="onChange"
format="YYYY-MM-DD HH:mm"
:placeholder="[$t('cmdb.history.startTime'), $t('cmdb.history.endTime')]"
v-else-if="attr.value_type === '3'"
v-else-if="item.value_type === '3'"
:show-time="{
hideDisabledOptions: true,
defaultValue: [moment('00:00:00', 'HH:mm:ss'), moment('23:59:59', 'HH:mm:ss')],

View File

@@ -7,6 +7,7 @@
:paneLengthPixel.sync="paneLengthPixel"
:appName="`cmdb-relation-views-${viewId}`"
:triggerLength="18"
calcBasedParent
>
<template #one>
<div class="relation-views-left" :style="{ height: `${windowHeight - 64}px` }">

View File

@@ -1,530 +1,106 @@
<template>
<div
class="resource-search"
:style="{ height: `${windowHeight - 93}px` }"
>
<div v-if="!isSearch" class="resource-search-before">
<div class="resource-search-title">
<ops-icon class="resource-search-title-icon" type="veops-resource11" />
<span class="resource-search-title-text">{{ $t('cmdb.ciType.resourceSearch') }}</span>
</div>
<SearchInput
ref="searchInputRef"
:CITypeGroup="CITypeGroup"
:allAttributesList="allAttributesList"
:searchValue="searchValue"
:selectCITypeIds="selectCITypeIds"
:expression="expression"
@changeFilter="changeFilter"
@updateAllAttributesList="updateAllAttributesList"
@saveCondition="saveCondition"
/>
<HistoryList
:recentList="recentList"
:favorList="favorList"
:detailCIId="detailCIId"
@clickRecent="clickRecent"
@deleteRecent="deleteRecent"
@clearRecent="clearRecent"
@deleteCollect="deleteCollect"
@showDetail="clickFavor"
/>
<img class="resource-search-before-bg" :src="require('@/modules/cmdb/assets/resourceSearch/resource_search_bg_1.png')" />
</div>
<div class="resource-search-after" v-else>
<div class="resource-search">
<div class="resource-search-tab">
<div
class="resource-search-after-left"
:style="{ width: showInstanceDetail ? '70%' : '100%' }"
v-for="(tab) in tabList"
:key="tab.value"
:class="['resource-search-tab-item', tabActive === tab.value ? 'resource-search-tab-item_active' : '']"
@click="tabActive = tab.value"
>
<SearchInput
ref="searchInputRef"
classType="after"
:CITypeGroup="CITypeGroup"
:allAttributesList="allAttributesList"
:searchValue="searchValue"
:selectCITypeIds="selectCITypeIds"
:expression="expression"
@changeFilter="changeFilter"
@updateAllAttributesList="updateAllAttributesList"
@saveCondition="saveCondition"
/>
<HistoryList
:recentList="recentList"
:favorList="favorList"
:detailCIId="detailCIId"
@clickRecent="clickRecent"
@deleteRecent="deleteRecent"
@clearRecent="clearRecent"
@deleteCollect="deleteCollect"
@showDetail="clickFavor"
/>
<div class="resource-search-divider"></div>
<InstanceList
:list="instanceList"
:tabList="ciTabList"
:referenceShowAttrNameMap="referenceShowAttrNameMap"
:referenceCIIdMap="referenceCIIdMap"
:favorList="favorList"
:detailCIId="detailCIId"
:searchValue="currentSearchValue"
@showDetail="showDetail"
@addCollect="addCollect"
@deleteCollect="deleteCollect"
/>
<div class="resource-search-pagination">
<a-pagination
:showSizeChanger="true"
:current="currentPage"
size="small"
:total="totalNumber"
show-quick-jumper
:page-size="pageSize"
:page-size-options="pageSizeOptions"
@showSizeChange="handlePageSizeChange"
:show-total="
(total, range) =>
$t('pagination.total', {
range0: range[0],
range1: range[1],
total,
})
"
@change="changePage"
>
<template slot="buildOptionText" slot-scope="props">
<span v-if="props.value !== '100000'">{{ props.value }}{{ $t('itemsPerPage') }}</span>
<span v-if="props.value === '100000'">{{ $t('all') }}</span>
</template>
</a-pagination>
</div>
</div>
<div v-if="showInstanceDetail" class="resource-search-after-right">
<InstanceDetail
:CIId="detailCIId"
:CITypeId="detailCITypeId"
:favorList="favorList"
@addCollect="addCollect"
@deleteCollect="deleteCollect"
@hideDetail="hideDetail"
/>
{{ $t(tab.lable) }}
</div>
</div>
<template v-if="isInit">
<ResourceSearchCom
v-show="tabActive === 'resourceSearch'"
:CITypeGroup="CITypeGroup"
:allCITypes="allCITypes"
/>
<RelationSearch
v-show="tabActive === 'relationSearch'"
:CITypeGroup="CITypeGroup"
:allCITypes="allCITypes"
/>
</template>
</div>
</template>
<script>
import _ from 'lodash'
import { getPreferenceSearch, savePreferenceSearch, getSubscribeAttributes, deletePreferenceSearch } from '@/modules/cmdb/api/preference'
import { getCITypeGroups } from '@/modules/cmdb/api/ciTypeGroup'
import { searchAttributes, getCITypeAttributesByTypeIds } from '@/modules/cmdb/api/CITypeAttr'
import { searchCI } from '@/modules/cmdb/api/ci'
import { getCITypes } from '@/modules/cmdb/api/CIType'
import { mapState } from 'vuex'
import SearchInput from './components/searchInput.vue'
import HistoryList from './components/historyList.vue'
import InstanceList from './components/instanceList.vue'
import InstanceDetail from './components/instanceDetail.vue'
import ResourceSearchCom from './resourceSearch/index.vue'
import RelationSearch from './relationSearch/index.vue'
export default {
name: 'ResourceSearch',
components: {
SearchInput,
HistoryList,
InstanceList,
InstanceDetail
ResourceSearchCom,
RelationSearch
},
data() {
return {
// 筛选条件
searchValue: '', // 搜索框
selectCITypeIds: [], // 已选模型
expression: '', // 筛选语句
currentSearchValue: '', // 当前已搜索语句
recentList: [], // 最近搜索
favorList: [], // 我的收藏
CITypeGroup: [], // CIType 分组
CITypes: [],
allAttributesList: [],
isSearch: false, // 是否搜索过
currentPage: 1,
pageSizeOptions: ['50', '100', '200', '100000'],
pageSize: 50,
totalNumber: 0,
ciTabList: [],
instanceList: [],
referenceShowAttrNameMap: {},
referenceCIIdMap: {},
showInstanceDetail: false,
detailCIId: -1,
detailCITypeId: -1,
tabActive: 'resourceSearch',
tabList: [
{
lable: 'cmdb.ciType.resourceSearch',
value: 'resourceSearch'
},
{
lable: 'cmdb.relationSearch.relationSearch',
value: 'relationSearch'
}
],
CITypeGroup: [],
allCITypes: [],
isInit: false,
}
},
computed: {
windowHeight() {
return this.$store.state.windowHeight
},
...mapState({
cmdbSearchValue: (state) => state.app.cmdbSearchValue,
}),
},
mounted() {
this.initData()
watch: {
cmdbSearchValue: {
immediate: true,
deep: true,
handler() {
this.tabActive = 'resourceSearch'
}
}
},
async mounted() {
try {
await Promise.all([
this.getCITypeGroups(),
this.getAllCITypes()
])
} catch (error) {
console.log('resource search mounted fail', error)
}
this.isInit = true
},
methods: {
async initData() {
await this.getRecentList()
await this.getFavorList()
await this.getCITypeGroups()
await this.getAllCITypes()
await this.getAllAttr()
},
async getRecentList() {
const recentList = await getPreferenceSearch({
name: '__recent__'
})
recentList.sort((a, b) => b.id - a.id)
this.recentList = recentList
},
async getFavorList() {
const favorList = await getPreferenceSearch({
name: '__favor__'
})
favorList.sort((a, b) => b.id - a.id)
this.favorList = favorList
},
async getCITypeGroups() {
const res = await getCITypeGroups({ need_other: true })
this.CITypeGroup = res
.filter((item) => item.ci_types && item.ci_types.length)
.filter((item) => item?.ci_types?.length)
.map((item) => {
item.id = `parent_${item.id || -1}`
return { ..._.cloneDeep(item) }
return item
})
},
async getAllCITypes() {
const res = await getCITypes()
this.CITypes = res?.ci_types
this.allCITypes = res?.ci_types
},
async getAllAttr() {
const res = await searchAttributes({ page_size: 9999 })
this.allAttributesList = res.attributes
this.originAllAttributesList = res.attributes
},
async updateAllAttributesList(value) {
if (value && value.length) {
const res = await getCITypeAttributesByTypeIds({ type_ids: value.join(',') })
this.allAttributesList = res.attributes
} else {
this.allAttributesList = this.originAllAttributesList
}
},
async saveCondition(isSubmit) {
if (
this.searchValue ||
this.expression ||
this.selectCITypeIds.length
) {
const needDeleteList = []
const differentList = []
this.recentList.forEach((item) => {
const option = item.option
if (
option.searchValue === this.searchValue &&
option.expression === this.expression &&
_.isEqual(option.ciTypeIds, this.selectCITypeIds)
) {
needDeleteList.push(item.id)
} else {
differentList.push(item.id)
}
})
if (differentList.length >= 10) {
needDeleteList.push(...differentList.slice(9))
}
if (needDeleteList.length) {
await Promise.all(
needDeleteList.map((id) => deletePreferenceSearch(id))
)
}
const ciTypeNames = this.selectCITypeIds.map((id) => {
const ciType = this.CITypes.find((item) => item.id === id)
return ciType?.alias || ciType?.name || id
})
await savePreferenceSearch({
option: {
searchValue: this.searchValue,
expression: this.expression,
ciTypeIds: this.selectCITypeIds,
ciTypeNames
},
name: '__recent__'
})
this.getRecentList()
}
if (isSubmit) {
this.isSearch = true
this.currentPage = 1
this.hideDetail()
this.loadInstance()
}
},
async deleteRecent(id) {
await deletePreferenceSearch(id)
this.getRecentList()
},
async clearRecent() {
const deletePromises = this.recentList.map((item) => {
return deletePreferenceSearch(item.id)
})
await Promise.all(deletePromises)
this.getRecentList()
},
async loadInstance() {
const { selectCITypeIds, expression, searchValue } = this
const regQ = /(?<=q=).+(?=&)|(?<=q=).+$/g
const exp = expression.match(regQ) ? expression.match(regQ)[0] : null
const ciTypeIds = [...selectCITypeIds]
if (!ciTypeIds.length) {
this.CITypeGroup.forEach((item) => {
const ids = item.ci_types.map((ci_type) => ci_type.id)
ciTypeIds.push(...ids)
})
}
const res = await searchCI({
q: `${ciTypeIds?.length ? `_type:(${ciTypeIds.join(';')})` : ''}${exp ? `,${exp}` : ''}${
searchValue ? `,*${searchValue}*` : ''
}`,
count: this.pageSize,
page: this.currentPage,
sort: '_type'
})
this.currentSearchValue = searchValue
this.totalNumber = res?.numfound ?? 0
if (!res?.result?.length) {
this.ciTabList = []
this.instanceList = []
}
const ciTabMap = new Map()
let list = res.result
list.forEach((item) => {
const ciType = this.CITypes.find((type) => type.id === item._type)
if (ciTabMap.has(item._type)) {
ciTabMap.get(item._type).count++
} else {
ciTabMap.set(item._type, {
id: item._type,
count: 1,
title: ciType?.alias || ciType?.name || '',
})
}
})
const mapEntries = [...ciTabMap.entries()]
const subscribedPromises = mapEntries.map((item) => {
return getSubscribeAttributes(item[0])
})
const subscribedRes = await Promise.all(subscribedPromises)
list = list.map((item) => {
const subscribedIndex = mapEntries.findIndex((mapValue) => mapValue[0] === item._type)
const subscribedAttr = subscribedRes?.[subscribedIndex]?.attributes || []
const obj = {
ci: item,
ciTypeObj: {},
attributes: subscribedAttr
}
const ciType = this.CITypes.find((type) => type.id === item._type)
obj.ciTypeObj = {
showAttrName: ciType?.show_name || ciType?.unique_key || '',
icon: ciType?.icon || '',
title: ciType?.alias || ciType?.name || '',
name: ciType?.name || '',
id: ciType.id
}
return obj
})
this.instanceList = list
const ciTabList = [...ciTabMap.values()]
if (list?.length) {
ciTabList.unshift({
id: -1,
title: this.$t('all'),
count: list?.length
})
}
this.ciTabList = ciTabList
// 处理引用属性
const allAttr = []
subscribedRes.map((item) => {
allAttr.push(...item.attributes)
})
this.handlePerference(_.uniqBy(allAttr, 'id'))
},
handlePerference(allAttr) {
let needRequiredCIType = []
allAttr.forEach((attr) => {
if (attr?.is_reference && attr?.reference_type_id) {
needRequiredCIType.push(attr)
}
})
needRequiredCIType = _.uniq(needRequiredCIType, 'id')
if (!needRequiredCIType.length) {
this.referenceShowAttrNameMap = {}
this.referenceCIIdMap = {}
return
}
this.handleReferenceShowAttrName(needRequiredCIType)
this.handleReferenceCIIdMap(needRequiredCIType)
},
async handleReferenceShowAttrName(needRequiredCIType) {
const res = await getCITypes({
type_ids: needRequiredCIType.map((col) => col.reference_type_id).join(',')
})
const map = {}
res.ci_types.forEach((ciType) => {
map[ciType.id] = ciType?.show_name || ciType?.unique_name || ''
})
this.referenceShowAttrNameMap = map
},
async handleReferenceCIIdMap(needRequiredCIType) {
const map = {}
this.instanceList.forEach(({ ci }) => {
needRequiredCIType.forEach((col) => {
const ids = Array.isArray(ci[col.name]) ? ci[col.name] : ci[col.name] ? [ci[col.name]] : []
if (ids.length) {
if (!map?.[col.reference_type_id]) {
map[col.reference_type_id] = {}
}
ids.forEach((id) => {
map[col.reference_type_id][id] = {}
})
}
})
})
if (!Object.keys(map).length) {
this.referenceCIIdMap = {}
return
}
const allRes = await Promise.all(
Object.keys(map).map((key) => {
return searchCI({
q: `_type:${key},_id:(${Object.keys(map[key]).join(';')})`,
count: 9999
})
})
)
allRes.forEach((res) => {
res.result.forEach((item) => {
if (map?.[item._type]?.[item._id]) {
map[item._type][item._id] = item
}
})
})
this.referenceCIIdMap = map
},
clickRecent(data) {
this.updateAllAttributesList(data.ciTypeIds || [])
this.isSearch = true
this.currentPage = 1
this.searchValue = data?.searchValue || ''
this.expression = data?.expression || ''
this.selectCITypeIds = data?.ciTypeIds || []
this.hideDetail()
this.loadInstance()
},
handlePageSizeChange(_, pageSize) {
this.pageSize = pageSize
this.currentPage = 1
this.loadInstance()
},
changePage(page) {
this.currentPage = page
this.loadInstance()
},
changeFilter(data) {
this[data.name] = data.value
},
showDetail(data) {
this.detailCIId = data.id
this.detailCITypeId = data.ciTypeId
this.showInstanceDetail = true
},
hideDetail() {
this.detailCIId = -1
this.detailCITypeId = -1
this.showInstanceDetail = false
},
async addCollect(data) {
if (this?.favorList?.length >= 10) {
const deletePromises = this.favorList.slice(9).map((item) => {
return deletePreferenceSearch(item.id)
})
await Promise.all(deletePromises)
}
await savePreferenceSearch({
option: {
...data
},
name: '__favor__'
})
this.getFavorList()
},
async deleteCollect(id) {
await deletePreferenceSearch(id)
this.getFavorList()
},
clickFavor(data) {
this.isSearch = true
this.showDetail(data)
}
}
},
}
</script>
@@ -532,84 +108,32 @@ export default {
.resource-search {
width: 100%;
height: 100%;
position: relative;
&-before {
width: 100%;
max-width: 725px;
height: 100%;
margin: 0 auto;
padding-top: 100px;
display: flex;
flex-direction: column;
align-items: center;
& > div {
position: relative;
z-index: 1;
}
&-bg {
position: absolute;
left: -24px;
bottom: -24px;
width: calc(100% + 48px);
z-index: 0;
}
}
&-title {
&-tab {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 25px;
margin-bottom: 12px;
&-icon {
font-size: 28px;
}
&-item {
padding-right: 8px;
margin-right: 8px;
font-size: 14px;
font-weight: 400;
color: #86909C;
cursor: pointer;
&-text {
margin-left: 10px;
font-size: 20px;
font-weight: 700;
color: #1D2129;
}
}
&:not(:last-child) {
border-right: solid 1px #E4E7ED;
}
&-after {
width: 100%;
height: 100%;
display: flex;
justify-content: space-between;
&:hover {
color: #2F54EB;
}
&-left {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
& > div {
flex-shrink: 0;
&_active {
color: #2F54EB;
}
}
&-right {
margin-left: 20px;
width: calc(30% - 20px);
flex-shrink: 0;
}
}
&-divider {
width: 100%;
height: 1px;
background-color: #E4E7ED;
margin: 20px 0;
}
&-pagination {
text-align: right;
margin: 12px 0px;
}
}
</style>

View File

@@ -0,0 +1,516 @@
<template>
<div class="search-table">
<div class="search-table-header">
<div class="table-tab">
<div
v-for="(tab) in tabList"
:key="tab.value"
:class="['table-tab-item', tabActive === tab.value ? 'table-tab-item_active' : '']"
@click="clickTab(tab.value)"
>
{{ tab.value }}
(<span class="table-tab-item-count">{{ tab.count }}</span>)
</div>
</div>
<a-button
v-if="tableData.ciList && tableData.ciList.length"
type="primary"
class="ops-button-ghost search-table-export"
ghost
@click="handleExport"
>
<ops-icon type="veops-export" />
{{ $t('export') }}
</a-button>
</div>
<ops-table
ref="xTable"
show-overflow
:data="tableData.ciList"
size="small"
:height="`${tableHeight}px`"
:cell-class-name="getCellClassName"
:header-cell-class-name="getHeaderCellClassName"
:checkbox-config="{ range: true }"
:loading="isSearchLoading"
:column-config="{ resizable: true }"
:resizable-config="{ minWidth: 60 }"
class="checkbox-hover-table"
>
<vxe-table-column
v-if="tableData.ciList && tableData.ciList.length"
align="center"
type="checkbox"
width="60"
>
<template #default="{row}">
{{ getRowSeq(row) }}
</template>
</vxe-table-column>
<template
v-if="returnPath && tableData.pathList && tableData.pathList.length"
>
<vxe-table-column
v-for="(path, index) in tableData.pathList"
class="table-path-column"
:key="`${path.id}-${index}`"
:title="tableData.pathList[index].name"
:field="path.id"
:show-header-overflow="false"
:width="index !== tableData.pathList.length - 1 ? 160 : 100"
>
<template #header>
<div class="table-path-header">
<span
class="table-path-header-name"
:style="{
maxWidth: tableData.pathList[index].relation ? '70px' : '100%'
}"
>
<a-tooltip :title="tableData.pathList[index].name">
{{ tableData.pathList[index].name }}
</a-tooltip>
</span>
<div
class="table-path-header-right"
v-if="tableData.pathList[index].relation"
>
<span class="table-path-header-line">
<a-icon
type="caret-right"
class="table-path-header-line-arrow"
/>
</span>
<span
class="table-path-header-relation"
>
<span class="table-path-header-relation-text">
<a-tooltip :title="tableData.pathList[index].relation">
{{ tableData.pathList[index].relation }}
</a-tooltip>
</span>
</span>
</div>
</div>
</template>
<template #default="{ row, columnIndex }">
<span
v-if="columnIndex === 1"
v-html="markSearchValue(row.pathCI[path.id])"
></span>
<span v-else >{{ row.pathCI[path.id] }}</span>
</template>
</vxe-table-column>
</template>
<template v-if="tableData.ciAttr && tableData.ciAttr.length">
<vxe-table-column
v-for="(attr, index) in tableData.ciAttr"
:key="`${attr.name}_${index}`"
:title="attr.alias || attr.name || ''"
:field="attr.name"
:width="attr.width"
:show-header-overflow="true"
>
<template #default="{ row }">
<AttrDisplay
:attr="attr"
:ci="row.targetCI"
:referenceShowAttrNameMap="referenceShowAttrNameMap"
:referenceCIIdMap="referenceCIIdMap"
/>
</template>
</vxe-table-column>
</template>
</ops-table>
<BatchDownload
ref="batchDownload"
:showFileTypeSelect="false"
@batchDownload="batchDownload"
/>
</div>
</template>
<script>
import _ from 'lodash'
import moment from 'moment'
import { mapState } from 'vuex'
import ExcelJS from 'exceljs'
import FileSaver from 'file-saver'
import AttrDisplay from '@/modules/cmdb/views/resource_search_2/resourceSearch/components/attrDisplay.vue'
import BatchDownload from '@/modules/cmdb/components/batchDownload/batchDownload.vue'
export default {
name: 'CITable',
components: {
AttrDisplay,
BatchDownload
},
props: {
allTableData: {
type: Object,
default: () => {}
},
tabActive: {
type: String,
default: ''
},
returnPath: {
type: Boolean,
default: false
},
isHideSearchCondition: {
type: Boolean,
default: false,
},
referenceShowAttrNameMap: {
type: Object,
default: () => {}
},
referenceCIIdMap: {
type: Object,
default: () => {}
},
searchValue: {
type: String,
default: ''
},
isSearchLoading: {
type: Boolean,
default: false
}
},
computed: {
...mapState({
windowHeight: (state) => state.windowHeight,
}),
tableHeight() {
return this.isHideSearchCondition ? this.windowHeight - 308 : this.windowHeight - 458
},
tableData() {
return this.allTableData?.[this.tabActive] || {}
},
tabList() {
const keys = Object.keys(this.allTableData) || []
return keys.map((key) => {
return {
value: key,
count: this.allTableData?.[key]?.count || 0
}
})
},
},
data() {
return {}
},
methods: {
markSearchValue(text) {
if (!text || !this.searchValue) {
return text
}
const regex = new RegExp(`(${this.searchValue})`, 'gi')
return String(text).replace(
regex,
`<span style="background-color: #D3EEFE; padding: 0 2px;">$1</span>`
)
},
clickTab(tab) {
this.$emit('updateTab', tab)
},
getRowSeq(row) {
const table = this.$refs?.['xTable']?.getVxetableRef?.() || null
return table?.getRowSeq?.(row)
},
getCellClassName({ columnIndex }) {
const pathLength = this.tableData?.pathList?.length
if (columnIndex <= pathLength && this.returnPath) {
return 'table-path-cell'
}
return ''
},
getHeaderCellClassName({ columnIndex }) {
const pathLength = this.tableData?.pathList?.length
if (columnIndex <= pathLength && this.returnPath) {
return 'table-path-header-cell'
}
return ''
},
handleExport() {
const preferenceAttrList = []
if (this.returnPath && this.tableData?.pathList?.length) {
preferenceAttrList.push(...this.tableData.pathList.map((path) => {
return {
name: path.id,
alias: path.name
}
}))
}
if (this.tableData?.ciAttr?.length) {
const ciAttr = _.cloneDeep(this.tableData.ciAttr)
ciAttr.forEach((attr) => {
attr.alias = attr.alias || attr.name
})
preferenceAttrList.push(...ciAttr)
}
this.$refs.batchDownload.open({
preferenceAttrList,
ciTypeName: this.tabActive || '',
})
},
batchDownload({ checkedKeys }) {
const excel_name = `cmdb-${this.tabActive}-${moment().format('YYYYMMDDHHmmss')}.xlsx`
const wb = new ExcelJS.Workbook()
const tableRef = this.$refs.xTable.getVxetableRef()
let tableData = _.cloneDeep([
...tableRef.getCheckboxReserveRecords(),
...tableRef.getCheckboxRecords(true),
])
if (!tableData.length) {
const { fullData } = tableRef.getTableData()
tableData = _.cloneDeep(fullData)
}
const ws = wb.addWorksheet(this.tabActive)
const pathColumns = []
const targetColumns = []
if (this.returnPath) {
const pathFilter = this.tableData.pathList.filter((path) => checkedKeys.includes(path.id))
pathFilter.forEach((path) => {
pathColumns.push({
header: path.name || '',
key: path.id,
width: 20,
})
})
}
const attrMap = new Map()
const attrFilter = this.tableData.ciAttr.filter((attr) => checkedKeys.includes(attr.name))
attrFilter.forEach((attr) => {
attrMap.set(attr.name, attr)
targetColumns.push({
header: attr.alias || attr.name || '',
key: attr.name,
width: 20,
})
})
ws.columns = [
...pathColumns,
...targetColumns
]
tableData.forEach(({ pathCI, targetCI }) => {
const row = {}
if (this.returnPath) {
pathColumns.forEach(({ key }) => {
row[key] = pathCI?.[key] || ''
})
}
targetColumns.forEach(({ key }) => {
const value = targetCI?.[key] ?? null
const attr = attrMap.get(key)
if (attr.valueType === '6') {
row[key] = value ? JSON.stringify(value) : value
} else if (attr.is_list && Array.isArray(value)) {
row[key] = value.join(',')
} else {
row[key] = value
}
})
ws.addRow(row)
})
wb.xlsx.writeBuffer().then((buffer) => {
const file = new Blob([buffer], {
type: 'application/octet-stream',
})
FileSaver.saveAs(file, excel_name)
})
this.$refs.xTable.getVxetableRef().clearCheckboxRow()
this.$refs.xTable.getVxetableRef().clearCheckboxReserve()
}
}
}
</script>
<style lang="less" scoped>
.search-table {
width: 100%;
&-header {
display: flex;
align-items: baseline;
justify-content: space-between
}
&-export {
flex-shrink: 0;
margin-left: 12px;
}
.table-tab {
display: flex;
align-items: center;
column-gap: 35px;
padding-bottom: 6px;
margin-bottom: 18px;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
&-item {
font-size: 14px;
font-weight: 400;
color: #4E5969;
cursor: pointer;
flex-shrink: 0;
&-count {
color: #2F54EB;
}
&_active {
color: #2F54EB;
}
&:hover {
color: #2F54EB;
}
}
}
.table-path-header {
position: relative;
display: flex;
align-items: center;
&-name {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
position: relative;
z-index: 1;
flex-shrink: 0;
}
&-right {
display: flex;
align-items: center;
width: 100%;
margin-left: 10px;
margin-right: -5px;
position: relative;
}
&-line {
width: 100%;
height: 1px;
position: relative;
background-color: #CACDD9;
z-index: 0;
&-arrow {
position: absolute;
right: -6px;
top: -6px;
font-size: 12px;
color: #CACDD9;
}
}
&-relation {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #FFFFFF;
border: solid 1px #E4E7ED;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border-radius: 22px;
z-index: 2;
max-width: 70px;
width: fit-content;
&-text {
font-size: 12px;
font-weight: 400;
color: #A5A9BC;
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
width: 100%;
}
}
}
.checkbox-hover-table {
/deep/ .vxe-table--body-wrapper {
.vxe-checkbox--label {
display: inline;
padding-left: 0px !important;
color: #bfbfbf;
}
.vxe-icon-checkbox-unchecked {
display: none;
}
.vxe-icon-checkbox-checked ~ .vxe-checkbox--label {
display: none;
}
.vxe-cell--checkbox {
&:hover {
.vxe-icon-checkbox-unchecked {
display: inline;
}
.vxe-checkbox--label {
display: none;
}
}
}
}
}
/deep/ .table-path-header-cell {
background-color: #EBEFF8 !important;
.vxe-cell--title {
width: 100%;
overflow: visible;
}
}
/deep/ .table-path-cell {
background-color: #F9FBFF;
}
/deep/ .attr-display {
display: inline;
}
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<a-popover
v-model="visible"
trigger="click"
placement="bottomRight"
@visibleChange="handleVisibleChange"
>
<div class="search-condition-filter">
<a-icon class="search-condition-filter-icon" type="filter" />
<div
v-if="expression"
class="search-condition-filter-flag"
>
</div>
</div>
<template slot="content">
<div class="search-condition-content">
<div class="search-condition-content-title">
{{ $t('cmdb.relationSearch.conditionFilter') }}:
</div>
<ConditionFilter
ref="conditionFilterRef"
:canSearchPreferenceAttrList="allAttributesList"
:expression="expression"
:CITypeIds="selectCITypeIds"
:isDropdown="false"
@setExpFromFilter="setExpFromFilter"
/>
<div class="search-condition-filter-submit">
<a-button
type="primary"
size="small"
@click="clickSubmit()"
>
{{ $t('confirm') }}
</a-button>
</div>
</div>
</template>
</a-popover>
</template>
<script>
import ConditionFilter from '@/modules/cmdb/components/conditionFilter/index.vue'
export default {
name: 'FilterPopover',
components: {
ConditionFilter
},
props: {
allAttributesList: {
type: Array,
default: () => []
},
selectCITypeIds: {
type: Array,
default: () => []
},
expression: {
type: String,
default: ''
},
},
data() {
return {
visible: false
}
},
methods: {
handleVisibleChange(open) {
if (open) {
this.$nextTick(() => {
this.$refs.conditionFilterRef.init(true, false)
})
}
},
clickSubmit() {
this.$refs.conditionFilterRef.handleSubmit()
this.visible = false
},
setExpFromFilter(filterExp) {
const regSort = /(?<=sort=).+/g
const expSort = this.expression.match(regSort) ? this.expression.match(regSort)[0] : undefined
let expression = ''
if (filterExp) {
expression = `q=${filterExp}`
}
if (expSort) {
expression += `&sort=${expSort}`
}
this.$emit('changeExpression', expression)
}
}
}
</script>
<style lang="less" scoped>
.search-condition-filter {
height: 32px;
width: 32px;
background-color: #FFFFFF;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
&-flag {
position: absolute;
right: -5px;
bottom: -5px;
width: 10px;
height: 10px;
border-radius: 10px;
background-color: #00B42A22;
display: flex;
align-items: center;
justify-content: center;
&::after {
content: '';
width: 5px;
height: 5px;
border-radius: 5px;
background-color: #00B42A;
}
}
&-icon {
font-size: 12px;
color: #A5A9BC;
}
&:hover {
.search-condition-filter-icon {
color: #2F54EB;
}
}
}
.search-condition-content {
min-width: 500px;
&-title {
font-size: 14px;
font-weight: 400;
color: #4E5969;
}
}
.search-condition-filter-submit {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<a-modal
:title="$t('cmdb.relationSearch.saveCondition')"
:visible="visible"
dialogClass="save-condition-modal"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form-model
ref="saveConditionForm"
:model="form"
:rules="formRule"
:labelCol="labelCol"
:wrapperCol="wrapperCol"
>
<a-form-model-item
:label="$t('cmdb.relationSearch.conditionName')"
prop="name"
>
<a-input v-model="form.name" />
</a-form-model-item>
</a-form-model>
</a-modal>
</template>
<script>
export default {
name: 'SaveConditionModal',
props: {
visible: {
type: Boolean,
default: false
}
},
computed: {
labelCol() {
return {
span: this.$i18n.locale === 'en' ? 7 : 4
}
},
wrapperCol() {
return {
span: this.$i18n.locale === 'en' ? 17 : 20
}
},
},
data() {
return {
form: {
name: ''
},
formRule: {
name: [
{ required: true, message: this.$t('placeholder1') }
],
}
}
},
methods: {
handleOk() {
this.$refs.saveConditionForm.validate((valid) => {
if (!valid) {
return
}
this.$emit('ok', {
name: this.form.name
})
this.handleCancel()
})
},
handleCancel() {
this.$refs.saveConditionForm.clearValidate()
this.form.name = ''
this.$emit('cancel')
}
}
}
</script>
<style lang="less" scoped>
.save-condition-modal {
/deep/ .ant-modal-close-x {
width: 48px;
height: 48px;
line-height: 48px;
}
/deep/ .ant-modal-body {
padding: 24px 18px;
}
/deep/ .ant-modal-footer {
padding: 10px 18px 18px;
}
}
</style>

View File

@@ -0,0 +1,728 @@
<template>
<div
class="search-condition"
:style="{
'--label-width': this.$i18n.locale === 'en' ? '90px' : '60px'
}"
>
<div class="search-condition-row">
<div class="search-condition-label">
{{ $t('cmdb.relationSearch.sourceCIType') }}
</div>
<div class="search-condition-control">
<treeselect
:value="sourceCIType"
class="custom-treeselect custom-treeselect-bgcAndBorder filter-content-ciTypes"
:style="{
width: '100%',
zIndex: '1000',
'--custom-height': '32px',
'--custom-bg-color': '#FFF',
'--custom-multiple-lineHeight': '32px',
}"
:multiple="false"
:clearable="true"
searchable
:options="CITypeGroup"
:limit="1"
:limitText="(count) => `+ ${count}`"
:disableBranchNodes="true"
:placeholder="$t('cmdb.relationSearch.sourceCITypeTip')"
:normalizer="
(node) => {
return {
id: node.id || -1,
label: node.alias || node.name || $t('other'),
title: node.alias || node.name || $t('other'),
children: node.ci_types,
}
}
"
@input="updateSourceCIType"
@open="handleSourceCITypeOpen"
>
<div
:title="node.label"
slot="option-label"
slot-scope="{ node }"
:style="{ width: '100%', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' }"
>
{{ node.label }}
</div>
</treeselect>
<a-input-search
class="search-condition-input"
:placeholder="$t('cmdb.relationSearch.sourceCITYpeInput')"
:value="sourceCITypeSearchValue"
@change="handleSourceCITypeSearchValueChange"
/>
</div>
<FilterPopover
:allAttributesList="sourceAllAttributesList"
:selectCITypeIds="sourceCIType ? [sourceCIType] : []"
:expression="sourceExpression"
@changeExpression="changeSourceExpression"
/>
</div>
<div class="search-condition-row">
<div class="search-condition-label">
{{ $t('cmdb.relationSearch.targetCIType') }}
</div>
<div class="search-condition-control">
<a-select
:value="targetCITypes"
show-search
optionFilterProp="children"
mode="multiple"
:placeholder="$t('cmdb.relationSearch.targetCITypeTip')"
class="search-condition-select"
@change="handleTargetCITypeChange"
>
<a-select-opt-group
v-for="(key, index) in Object.keys(targetCITypeGroup)"
:key="key"
:label="$t('cmdb.relationSearch.level') + `${index + 1}`"
>
<a-select-option
v-for="citype in targetCITypeGroup[key]"
:key="citype.id"
:value="citype.id"
>
{{ citype.alias || citype.name }}
</a-select-option>
</a-select-opt-group>
</a-select>
</div>
<FilterPopover
:allAttributesList="targetAllAttributesList"
:selectCITypeIds="targetCITypes"
:expression="targetExpression"
@changeExpression="changeTargetExpression"
/>
</div>
<div class="search-condition-row">
<div class="search-condition-label">
{{ $t('cmdb.relationSearch.pathSelect') }}
</div>
<div class="search-condition-control">
<a-dropdown
v-model="pathSelectVisible"
:trigger="['click']"
:getPopupContainer="(trigger) => trigger.parentElement"
>
<a-input
:value="pathDisplay"
readOnly
:placeholder="$t('cmdb.relationSearch.pathSelectTip')"
class="search-condition-input"
@click="e => e.preventDefault()"
>
<a-icon
slot="suffix"
type="caret-down"
class="search-condition-input-suffix"
/>
</a-input>
<div @click="clickPathSelectDropdown" slot="overlay">
<template v-if="allPath.length" >
<a-checkbox-group
:value="selectedPath"
class="search-condition-checkbox"
@change="handlePathChange"
>
<a-checkbox
v-for="(path) in allPath"
:key="path.value"
:value="path.value"
class="search-condition-checkbox-item"
>
<a-tooltip :title="path.pathNames">
<span class="search-condition-checkbox-item-name">
{{ path.pathNames }}
</span>
</a-tooltip>
</a-checkbox>
</a-checkbox-group>
<div class="search-condition-path-divider"></div>
<div class="search-condition-path-switch">
<span>{{ $t('cmdb.relationSearch.returnPath') }}</span>
<a-switch
:checked="returnPath"
@change="handleReturnPathChange"
/>
</div>
</template>
<div
v-else
class="search-condition-path-null"
>
<img
:src="require('@/assets/data_empty.png')"
class="search-condition-path-null-img"
/>
<div class="search-condition-path-null-text">{{ $t('noData') }}</div>
</div>
</div>
</a-dropdown>
</div>
<div
:class="['search-condition-submit', isSearchLoading ? 'search-condition-submit-loading' : '']"
@click="clickSubmit"
>
<a-icon
:type="isSearchLoading ? 'loading' : 'search'"
class="search-condition-submit-icon"
/>
</div>
</div>
<div class="search-condition-favor">
<div class="search-condition-favor-list">
<div
v-for="(item) in favorList"
:key="item.id"
class="search-condition-favor-item"
@click="clickFavor(item)"
>
<div class="search-condition-favor-name">
{{ item.option.name }}
</div>
<a-icon
@click.stop="deleteFavor(item.id)"
type="close"
class="search-condition-favor-close"
/>
</div>
</div>
<div class="search-condition-favor-right">
<a
class="search-condition-save"
@click="saveCondition"
>
<ops-icon
type="veops-save"
class="search-condition-save-icon"
/>
<span class="search-condition-save-text">
{{ $t('cmdb.relationSearch.saveCondition') }}
</span>
</a>
<div
v-if="isSearch"
class="search-condition-hide"
@click="hideSearchCondition"
>
<a-icon
type="up"
class="search-condition-hide-icon"
/>
</div>
</div>
</div>
<SaveConditionModal
:visible="saveConditionVisible"
@ok="handleSaveConditionOk"
@cancel="saveConditionVisible = false"
/>
</div>
</template>
<script>
import { getPreferenceSearch, savePreferenceSearch, deletePreferenceSearch } from '@/modules/cmdb/api/preference'
import FilterPopover from './filterPopover.vue'
import SaveConditionModal from './saveConditionModal.vue'
export default {
name: 'SearchCondition',
components: {
FilterPopover,
SaveConditionModal
},
props: {
CITypeGroup: {
type: Array,
default: () => []
},
sourceCIType: {
type: [Number, undefined],
default: undefined
},
sourceCITypeSearchValue: {
type: String,
default: ''
},
sourceAllAttributesList: {
type: Array,
default: () => []
},
sourceExpression: {
type: String,
default: ''
},
targetCITypes: {
type: Array,
default: () => []
},
targetCITypeGroup: {
type: Object,
default: () => {}
},
targetAllAttributesList: {
type: Array,
default: () => []
},
targetExpression: {
type: String,
default: ''
},
returnPath: {
type: Boolean,
default: false
},
allPath: {
type: Array,
default: () => []
},
selectedPath: {
type: Array,
default: () => []
},
isSearch: {
type: Boolean,
default: false,
},
isSearchLoading: {
type: Boolean,
default: false
}
},
data() {
return {
oldsourceCIType: undefined,
saveConditionVisible: false,
pathSelectVisible: false,
favorList: [],
relationSearchFavorKey: '__relation_favor__'
}
},
computed: {
pathDisplay() {
return this.allPath?.filter((path) => this?.selectedPath?.includes?.(path?.value))?.map((path) => path?.pathNames)?.join(', ') || ''
}
},
mounted() {
this.getFavorList()
},
methods: {
async getFavorList() {
const favorList = await getPreferenceSearch({
name: this.relationSearchFavorKey
})
favorList.sort((a, b) => b.id - a.id)
this.favorList = favorList
},
updateSourceCIType(value) {
this.$emit('changeData', {
name: 'sourceCIType',
value
})
},
handleSourceCITypeSearchValueChange(e) {
const value = e.target.value
this.$emit('changeData', {
name: 'sourceCITypeSearchValue',
value
})
},
changeSourceExpression(expression) {
this.$emit('changeData', {
name: 'sourceExpression',
value: expression
})
},
handleTargetCITypeChange(value) {
this.$emit('changeData', {
name: 'targetCITypes',
value
})
},
changeTargetExpression(expression) {
this.$emit('changeData', {
name: 'targetExpression',
value: expression
})
},
handlePathChange(value) {
this.$emit('changeData', {
name: 'selectedPath',
value
})
},
handleReturnPathChange(checked) {
this.$emit('changeData', {
name: 'returnPath',
value: checked
})
},
clickSubmit() {
if (this.isSearchLoading) {
return
}
if (this.validateControl()) {
return
}
this.$emit('search')
},
validateControl() {
if (!this.sourceCIType) {
this.$message.warning(`${this.$t('placeholder2')} ${this.$t('cmdb.relationSearch.sourceCIType')}`)
return true
}
if (!this.targetCITypes.length) {
this.$message.warning(`${this.$t('placeholder2')} ${this.$t('cmdb.relationSearch.targetCIType')}`)
return true
}
if (!this.selectedPath.length) {
this.$message.warning(`${this.$t('placeholder2')} ${this.$t('cmdb.relationSearch.path')}`)
return true
}
return false
},
saveCondition() {
if (this.validateControl()) {
return
}
this.saveConditionVisible = true
},
async handleSaveConditionOk({ name }) {
if (this?.favorList?.length >= 10) {
const deletePromises = this.favorList.slice(9).map((item) => {
return deletePreferenceSearch(item.id)
})
await Promise.all(deletePromises)
}
const option = {
name,
sourceCIType: this.sourceCIType,
searchValue: this.sourceCITypeSearchValue,
sourceExpression: this.sourceExpression,
targetCITypes: this.targetCITypes,
targetExpression: this.targetExpression,
selectedPath: this.selectedPath,
}
savePreferenceSearch({
option: {
...option
},
name: this.relationSearchFavorKey
}).then(() => {
this.$message.success(this.$t('saveSuccess'))
this.getFavorList()
})
},
deleteFavor(id) {
deletePreferenceSearch(id).then(() => {
this.$message.success(this.$t('deleteSuccess'))
this.getFavorList()
})
},
hideSearchCondition() {
this.$emit('hideSearchCondition')
},
clickPathSelectDropdown(e) {
if (e.key === '3') {
this.pathSelectVisible = false
}
},
clickFavor(data) {
if (data?.option) {
this.$emit('clickFavor', data.option)
}
},
handleSourceCITypeOpen() {
this.pathSelectVisible = false
}
}
}
</script>
<style lang="less" scoped>
.search-condition {
&-row {
display: flex;
align-items: center;
margin-bottom: 24px;
column-gap: 15px;
}
&-label {
font-size: 14px;
font-weight: 400;
color: #000000;
width: var(--label-width);
}
&-control {
display: flex;
align-items: center;
column-gap: 12px;
width: 500px;
/deep/ .ant-dropdown-content {
background-color: #FFFFFF;
padding: 14px 18px;
width: 500px;
}
}
&-input {
width: 100%;
/deep/ .ant-input {
border: none;
box-shadow: none;
cursor: pointer;
}
&-suffix {
color: #CACDD9;
}
}
&-select {
width: 100%;
/deep/ .ant-select-selection {
border: none;
box-shadow: none;
}
}
&-path {
&-divider {
width: 100%;
margin: 20px 0;
height: 1px;
background-color: #E4E7ED;
}
&-switch {
display: flex;
align-items: center;
column-gap: 16px;
}
}
&-checkbox {
display: flex;
flex-direction: column;
max-height: 300px;
overflow-y: auto;
overflow-x: hidden;
&-item {
margin: 0px;
display: flex;
align-items: center;
/deep/ & > span:first-child {
flex-shrink: 0;
}
/deep/ & > span:last-child {
width: 100%;
}
&-name {
overflow: hidden;
text-wrap: nowrap;
text-overflow: ellipsis;
display: inline-block;
max-width: 100%;
}
&:not(:last-child) {
margin-bottom: 16px;
}
}
}
&-path-null {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
&-img {
width: 100px;
}
&-text {
margin-top: 12px;
color: #A5A9BC;
}
}
&-submit {
width: 32px;
height: 32px;
cursor: pointer;
border-radius: 2px;
background-color: #2F54EB;
display: flex;
align-items: center;
justify-content: center;
&-icon {
font-size: 12px;
color: #FFFFFF;
}
&-loading {
background-color: #2F54EB90;
}
&:hover {
background-color: #2F54EB90;
}
}
&-favor {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 24px;
column-gap: 15px;
&-list {
max-width: 500px;
display: flex;
align-items: center;
column-gap: 14px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 4px;
}
&-name {
font-size: 12px;
font-weight: 400;
color: #4E5969;
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
}
&-close {
font-size: 12px;
color: #4E5969;
flex-shrink: 0;
&:hover {
color: #4E596980;
}
}
&-right {
display: flex;
align-items: center;
flex-shrink: 0;
}
&-item {
display: flex;
align-items: center;
max-width: 150px;
background-color: #EBEFF8;
border-radius: 28px;
padding: 2px 12px;
column-gap: 3px;
cursor: pointer;
&:hover {
background-color: #D9E4FA;
.search-condition-favor-name {
color: #2F54EB;
}
.search-condition-favor-close {
color: #2F54EB;
}
}
}
}
&-save {
flex-shrink: 0;
display: flex;
align-items: center;
font-size: 12px;
column-gap: 7px;
}
&-hide {
width: 18px;
height: 18px;
background-color: #EBEFF8;
border-radius: 1px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-left: 22px;
&-icon {
font-size: 12px;
color: #86909C;
}
&:hover {
background-color: #D9E4FA;
.search-condition-hide-icon {
color: #2F54EB;
}
}
}
}
</style>

View File

@@ -0,0 +1,693 @@
<template>
<div
ref="relationSearchRef"
class="relation-search"
:style="{ height: `${windowHeight - 131}px` }"
>
<div class="relation-search-wrap">
<div
v-if="!isSearch"
class="relation-search-title"
>
<ops-icon class="relation-search-title-icon" type="veops-relationship2" />
<div class="relation-search-title-text">{{ $t('cmdb.relationSearch.relationSearch') }}</div>
</div>
<div
v-if="isHideSearchCondition"
class="relation-search-expand"
>
<div class="relation-search-expand-line"></div>
<div class="relation-search-expand-right">
<div
class="relation-search-expand-handle"
@click="isHideSearchCondition = false"
>
<a-icon
type="down"
class="relation-search-expand-icon"
/>
</div>
<div
class="relation-search-expand-text"
@click="isHideSearchCondition = false"
>
{{ $t('cmdb.relationSearch.expandCondition') }}
</div>
</div>
</div>
<SearchCondition
v-else
:CITypeGroup="CITypeGroup"
:sourceCIType="sourceCIType"
:sourceCITypeSearchValue="sourceCITypeSearchValue"
:sourceAllAttributesList="sourceAllAttributesList"
:sourceExpression="sourceExpression"
:targetCITypes="targetCITypes"
:targetCITypeGroup="targetCITypeGroup"
:targetAllAttributesList="targetAllAttributesList"
:targetExpression="targetExpression"
:returnPath="returnPath"
:allPath="allPath"
:selectedPath="selectedPath"
:isSearch="isSearch"
:isSearchLoading="isSearchLoading"
@changeData="changeData"
@search="handleSearch"
@hideSearchCondition="isHideSearchCondition = true"
@clickFavor="clickFavor"
/>
<div
v-if="isSearch"
class="relation-search-main"
>
<CITable
:allTableData="allTableData"
:tabActive="tableTabActive"
:returnPath="returnPath"
:isHideSearchCondition="isHideSearchCondition"
:referenceShowAttrNameMap="referenceShowAttrNameMap"
:referenceCIIdMap="referenceCIIdMap"
:searchValue="sourceCITypeSearchValue"
:isSearchLoading="isSearchLoading"
@updateTab="(tab) => tableTabActive = tab"
/>
<div class="relation-search-pagination">
<a-pagination
:showSizeChanger="true"
:current="page"
size="small"
:total="totalNumber"
show-quick-jumper
:page-size="pageSize"
:page-size-options="pageSizeOptions"
:show-total="
(total, range) =>
$t('pagination.total', {
range0: range[0],
range1: range[1],
total,
})
"
@showSizeChange="handlePageSizeChange"
@change="changePage"
>
<template slot="buildOptionText" slot-scope="props">
<span v-if="props.value !== '100000'">{{ props.value }}{{ $t('itemsPerPage') }}</span>
<span v-if="props.value === '100000'">{{ $t('all') }}</span>
</template>
</a-pagination>
</div>
</div>
</div>
<img
v-if="!isSearch"
class="relation-search-bg"
:src="require('@/modules/cmdb/assets/resourceSearch/resource_search_bg_1.png')"
/>
</div>
</template>
<script>
import _ from 'lodash'
import { getCITypeAttributesByTypeIds } from '@/modules/cmdb/api/CITypeAttr'
import { getRecursive_level2children, getCITypeRelationPath } from '@/modules/cmdb/api/CITypeRelation'
import { searchCIRelationPath } from '@/modules/cmdb/api/CIRelation'
import { getCITypes } from '@/modules/cmdb/api/CIType'
import { getSubscribeAttributes } from '@/modules/cmdb/api/preference'
import { searchCI } from '@/modules/cmdb/api/ci'
import { strLength } from '@/modules/cmdb/utils/helper.js'
import SearchCondition from './components/searchCondition.vue'
import CITable from './components/ciTable.vue'
export default {
name: 'RelationSearch',
components: {
SearchCondition,
CITable
},
props: {
CITypeGroup: {
type: Array,
default: () => []
},
allCITypes: {
type: Array,
default: () => []
}
},
data() {
return {
isSearch: false, // 是否搜索
isHideSearchCondition: false, // 是否隐藏搜索条件
isWatchData: true, // 是否监听数据变化
isSearchLoading: false, // 搜索中
sourceCIType: undefined, // 已选源模型
sourceCITypeSearchValue: '', // 源模型搜索关键词
sourceAllAttributesList: [], // 源模型所有属性
sourceExpression: '', // 源模型表达式
targetCITypes: [], // 目标模型
targetCITypeGroup: {}, // 目标模型分组
targetAllAttributesList: [], // 目标模型所有属性
targetExpression: '', // 目标模型表达式
returnPath: true, // 表格是否展示路径详情
allPath: [], // 所有路径选项
selectedPath: [], // 已选择路径
// table
page: 1,
pageSize: 50,
pageSizeOptions: ['50', '100', '200'],
allTableData: {}, // 表格数据
totalNumber: 0, // 数据总数
tableTabActive: '', // 当前 table tab
referenceShowAttrNameMap: {},
referenceCIIdMap: {},
}
},
computed: {
windowHeight() {
return this.$store.state.windowHeight
},
watchParams() {
return {
sourceCIType: this.sourceCIType,
targetCITypes: this.targetCITypes
}
},
},
watch: {
sourceCIType: {
immediate: true,
deep: true,
handler(id) {
if (this.isWatchData) {
this.sourceExpression = ''
this.targetCITypes = []
this.targetAllAttributesList = []
this.targetExpression = ''
this.selectedPath = []
this.getTargetCITypeGroup(id)
this.updateSourceAllAttributesList(id)
}
}
},
targetCITypes: {
immediate: true,
deep: true,
handler(ids) {
if (this.isWatchData) {
this.selectedPath = []
this.targetExpression = ''
this.updateTargetAllAttributesList(ids)
}
}
},
watchParams: {
immediate: true,
deep: true,
handler(data) {
if (this.isWatchData) {
this.updateAllPath(data)
}
}
}
},
methods: {
changeData(data) {
this[data.name] = data.value
},
async updateSourceAllAttributesList(id) {
if (id) {
const res = await getCITypeAttributesByTypeIds({ type_ids: id })
this.sourceAllAttributesList = res.attributes
} else {
this.sourceAllAttributesList = []
}
},
async getTargetCITypeGroup(id) {
let targetCITypeGroup = {}
if (id) {
const res = await getRecursive_level2children(id)
targetCITypeGroup = res
}
this.targetCITypeGroup = targetCITypeGroup
},
async updateTargetAllAttributesList(ids) {
if (ids?.length) {
const res = await getCITypeAttributesByTypeIds({ type_ids: ids.join(',') })
this.targetAllAttributesList = res.attributes
} else {
this.targetAllAttributesList = []
}
},
async updateAllPath(data) {
let allPath = []
if (
data.sourceCIType &&
data?.targetCITypes?.length
) {
const params = {
source_type_id: data.sourceCIType,
target_type_ids: data.targetCITypes.join(',')
}
const res = await getCITypeRelationPath(params)
if (res?.paths?.length) {
const sourceCIType = this.allCITypes.find((ciType) => ciType.id === data.sourceCIType)
const sourceCITypeName = sourceCIType?.alias || sourceCIType?.name || ''
const targetCITypeList = Object.values(this.targetCITypeGroup).reduce((acc, cur) => acc.concat(cur), [])
allPath = res.paths.map((ids) => {
const [sourceId, ...targetIds] = ids
const pathNames = [sourceCITypeName]
targetIds.forEach((id) => {
const ciType = targetCITypeList.find((item) => item.id === id)
if (ciType) {
pathNames.push(ciType.alias || ciType.name)
}
})
return {
value: ids.join(','),
sourceId,
targetIds,
pathNames: pathNames.join('-'),
}
})
}
}
this.allPath = allPath
},
async loadCI() {
this.isSearchLoading = true
const path = this.selectedPath.map((item) => {
return item?.split(',')?.map((id) => Number(id)) || []
})
const params = {
page: this.page,
page_size: this.pageSize,
source: {
type_id: this.sourceCIType
},
target: {
type_ids: this.targetCITypes
},
path
}
const regQ = /(?<=q=).+(?=&)|(?<=q=).+$/g
const sourceExp = this.sourceExpression.match(regQ) ? this.sourceExpression.match(regQ)[0] : null
const targetExp = this.targetExpression.match(regQ) ? this.targetExpression.match(regQ)[0] : null
const sourceSearch = `${sourceExp ? `${sourceExp}` : ''}${this.sourceCITypeSearchValue ? `,*${this.sourceCITypeSearchValue}*` : ''}`
if (sourceSearch) {
params.source.q = sourceSearch
}
if (targetExp) {
params.target.q = targetExp
}
let res = {}
const tableData = {}
const typeId2Attr = {}
let pathKeyList = []
try {
res = await searchCIRelationPath(params)
pathKeyList = Object.keys(res.paths)
const filterAllPath = this.allPath.filter((path) => pathKeyList.includes(path.pathNames))
const typeIds = _.uniq(
filterAllPath.map((item) => item?.targetIds?.[item?.targetIds?.length - 1])
)
const promises = typeIds.map((id) => {
return getSubscribeAttributes(id)
})
const subscribedRes = await Promise.all(promises)
typeIds.forEach((id, index) => {
const attrList = subscribedRes?.[index]?.attributes || []
typeId2Attr[id] = attrList
})
} catch (error) {
this.isSearchLoading = false
this.allTableData = {}
this.totalNumber = 0
this.tableTabActive = ''
return
}
pathKeyList.forEach((key) => {
const pathObj = this.allPath.find((path) => path.pathNames === key)
const pathIdList = pathObj?.value?.split(',') || []
const pathNameList = key?.split('-') || []
const pathList = pathNameList.map((name, index) => {
let relation = ''
if (index < pathNameList.length - 1) {
const targetName = pathNameList[index + 1]
const sourceRelation = res?.relation_types?.[name]
if (sourceRelation) {
if (Object.keys(sourceRelation)?.includes?.(targetName)) {
relation = sourceRelation?.[targetName] || ''
}
}
}
return {
id: pathIdList?.[index] || '',
name,
relation
}
})
tableData[key] = {
key,
count: res.paths?.[key]?.length || 0,
pathList,
ciAttr: [],
ciList: []
}
if (pathObj) {
const firstIds = res?.paths?.[key]?.[0]
const targetId = firstIds[firstIds.length - 1]
const ciTypeId = (res?.id2ci?.[targetId] || {})?._type
if (ciTypeId) {
tableData[key].ciAttr = typeId2Attr[ciTypeId]
}
tableData[key].ciList = res.paths[key].map((ids) => {
const pathCI = {}
ids.map((id) => {
const ci = res?.id2ci?.[id] || {}
const showAttr = res?.type2show_key?.[ci._type] || ''
pathCI[ci._type] = ci?.[showAttr] ?? ''
})
const targetId = ids[ids.length - 1]
const targetCI = res?.id2ci?.[targetId] || {}
return {
pathCI,
targetCI
}
})
let totalWidth = 0
tableData[key].ciAttr.forEach((attr) => {
const lengthList = tableData[key].ciList.map(({ targetCI }) => {
return strLength(targetCI[attr.name])
})
attr.width = Math.round(Math.min(Math.max(100, ...lengthList), 350))
totalWidth += attr.width
})
// ci 表格宽度 = 容器宽度 - path 列宽 - checkbox 宽度
const wrapWidth = this.$refs?.relationSearchRef?.clientWidth - (tableData?.[key]?.pathList.length || 0) * 160 - 60
if (wrapWidth && totalWidth < wrapWidth) {
tableData[key].ciAttr.forEach((attr) => {
delete attr.width
})
}
}
})
this.$set(this, 'allTableData', tableData)
this.allTableData = tableData
this.totalNumber = res?.numfound ?? 0
this.tableTabActive = Object.keys(tableData)?.[0] || ''
this.isSearch = true
this.isSearchLoading = false
const allAttr = []
Object.values(typeId2Attr).map((attrList) => {
allAttr.push(...attrList)
})
this.handlePerference(_.uniqBy(allAttr, 'id'))
},
handlePerference(allAttr) {
let needRequiredCIType = []
allAttr.forEach((attr) => {
if (attr?.is_reference && attr?.reference_type_id) {
needRequiredCIType.push(attr)
}
})
needRequiredCIType = _.uniq(needRequiredCIType, 'id')
if (!needRequiredCIType.length) {
this.referenceShowAttrNameMap = {}
this.referenceCIIdMap = {}
return
}
this.handleReferenceShowAttrName(needRequiredCIType)
this.handleReferenceCIIdMap(needRequiredCIType)
},
async handleReferenceShowAttrName(needRequiredCIType) {
const res = await getCITypes({
type_ids: needRequiredCIType.map((col) => col.reference_type_id).join(',')
})
const map = {}
res.ci_types.forEach((ciType) => {
map[ciType.id] = ciType?.show_name || ciType?.unique_name || ''
})
this.referenceShowAttrNameMap = map
},
async handleReferenceCIIdMap(needRequiredCIType) {
const map = {}
Object.values(this.allTableData).forEach((item) => {
const ciList = item?.ciList || []
ciList.forEach(({ targetCI }) => {
needRequiredCIType.forEach((col) => {
const ids = Array.isArray(targetCI[col.name]) ? targetCI[col.name] : targetCI[col.name] ? [targetCI[col.name]] : []
if (ids.length) {
if (!map?.[col.reference_type_id]) {
map[col.reference_type_id] = {}
}
ids.forEach((id) => {
map[col.reference_type_id][id] = {}
})
}
})
})
})
if (!Object.keys(map).length) {
this.referenceCIIdMap = {}
return
}
const allRes = await Promise.all(
Object.keys(map).map((key) => {
return searchCI({
q: `_type:${key},_id:(${Object.keys(map[key]).join(';')})`,
count: 9999
})
})
)
allRes.forEach((res) => {
res.result.forEach((item) => {
if (map?.[item._type]?.[item._id]) {
map[item._type][item._id] = item
}
})
})
this.referenceCIIdMap = map
},
handlePageSizeChange(_, pageSize) {
this.pageSize = pageSize
this.page = 1
this.loadCI()
},
changePage(page) {
this.page = page
this.loadCI()
},
handleSearch() {
this.page = 1
this.loadCI()
},
clickFavor(option) {
this.isWatchData = false
this.$nextTick(async () => {
this.sourceCIType = option?.sourceCIType || undefined
this.sourceCITypeSearchValue = option?.searchValue || ''
this.sourceExpression = option?.sourceExpression || ''
this.targetCITypes = option?.targetCITypes || []
this.targetExpression = option?.targetExpression || ''
this.selectedPath = option?.selectedPath || []
await Promise.all([
this.getTargetCITypeGroup(this.sourceCIType),
this.updateSourceAllAttributesList(this.sourceCIType),
this.updateTargetAllAttributesList(this.targetCITypes)
])
await this.updateAllPath({
sourceCIType: this.sourceCIType,
targetCITypes: this.targetCITypes
})
this.isWatchData = true
this.page = 1
this.loadCI()
})
}
}
}
</script>
<style lang="less" scoped>
.relation-search {
width: 100%;
height: 100%;
position: relative;
&-wrap {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 1;
}
&-title {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 30px;
margin-top: 100px;
&-icon {
font-size: 28px;
margin-right: 10px;
}
&-text {
font-size: 20px;
font-weight: 700;
color: #1D2129;
}
}
&-expand {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
&-line {
width: 650px;
height: 1px;
background-color: #E4E7ED;
}
&-icon {
font-size: 12px;
color: #86909C;
}
&-text {
margin-left: 5px;
font-size: 12px;
font-weight: 400;
color: #A5A9BC;
}
&-handle {
width: 14px;
height: 14px;
background-color: #EBEFF8;
border-radius: 1px;
display: flex;
align-items: center;
justify-content: center;
}
&-right {
flex-shrink: 0;
display: flex;
align-items: center;
cursor: pointer;
&:hover {
.relation-search-expand-handle {
background-color: #D9E4FA;
}
.relation-search-expand-icon {
color: #2F54EB;
}
.relation-search-expand-text {
color: #2F54EB;
}
}
}
}
&-bg {
position: absolute;
left: -24px;
bottom: -24px;
width: calc(100% + 48px);
z-index: 0;
}
&-main {
width: calc(100% + 48px);
// height: 100%;
background-color: #FFFFFF;
padding: 24px;
}
&-pagination {
text-align: right;
margin-top: 12px;
}
}
</style>

View File

@@ -0,0 +1,156 @@
<template>
<div :class="['attr-display', isEllipsis ? 'attr-display-ellipsis' : '']">
<template v-if="attr.is_reference && ci[attr.name]" >
<a
v-for="(ciId) in (attr.is_list ? ci[attr.name] : [ci[attr.name]])"
:key="ciId"
:href="`/cmdb/cidetail/${attr.reference_type_id}/${ciId}`"
target="_blank"
>
{{ getReferenceAttrValue(ciId) }}
</a>
</template>
<span v-else-if="attr.value_type === '6' && ci[attr.name]">{{ JSON.stringify(ci[attr.name]) }}</span>
<template v-else-if="attr.is_link && ci[attr.name]">
<a
v-for="(item, linkIndex) in (attr.is_list ? ci[attr.name] : [ci[attr.name]])"
:key="linkIndex"
:href="
item.startsWith('http') || item.startsWith('https')
? `${item}`
: `http://${item}`
"
target="_blank"
>
{{ getChoiceValueLabel(item) || item }}
</a>
</template>
<PasswordField
v-else-if="attr.is_password && ci[attr.name]"
:ci_id="ci._id"
:attr_id="attr.id"
></PasswordField>
<template v-else-if="attr.is_choice">
<span
v-for="value in (attr.is_list ? ci[attr.name] : [ci[attr.name]])"
:key="value"
:style="{
borderRadius: '4px',
padding: '1px 5px',
margin: '2px',
...getChoiceValueStyle(value),
}"
>
<ops-icon
:style="{ color: getChoiceValueIcon(attr, value).color }"
:type="getChoiceValueIcon(attr, value).name"
/>
<span
v-html="markSearchValue(getChoiceValueLabel(value) || value)"
></span>
</span>
</template>
<span
v-else
v-html="markSearchValue((attr.is_list && Array.isArray(ci[attr.name])) ? ci[attr.name].join(',') : ci[attr.name])"
></span>
</div>
</template>
<script>
import PasswordField from '@/modules/cmdb/components/passwordField/index.vue'
export default {
name: 'AttrDisplay',
components: {
PasswordField
},
props: {
attr: {
type: Object,
default: () => {}
},
ci: {
type: Object,
default: () => {}
},
isEllipsis: {
type: Boolean,
default: false
},
referenceShowAttrNameMap: {
type: Object,
default: () => {}
},
referenceCIIdMap: {
type: Object,
default: () => {}
},
searchValue: {
type: String,
default: ''
}
},
methods: {
markSearchValue(text) {
if (!text || !this.searchValue) {
return text
}
const regex = new RegExp(`(${this.searchValue})`, 'gi')
return String(text).replace(
regex,
`<span style="background-color: #D3EEFE; padding: 0 2px;">$1</span>`
)
},
getChoiceValueStyle(attrValue) {
const _find = this?.attr?.choice_value?.find?.((item) => String(item?.[0]) === String(attrValue))
if (_find) {
return _find?.[1]?.style || {}
}
return {}
},
getChoiceValueIcon(attrValue) {
const _find = this?.attr?.choice_value?.find((item) => String(item?.[0]) === String(attrValue))
if (_find) {
return _find?.[1]?.icon || {}
}
return {}
},
getChoiceValueLabel(attrValue) {
const _find = this?.attr?.choice_value?.find((item) => String(item?.[0]) === String(attrValue))
if (_find) {
return _find?.[1]?.label || ''
}
return ''
},
getReferenceAttrValue(id) {
if (this.attr.referenceShowAttrNameMap?.[id]) {
return this.attr.referenceShowAttrNameMap[id]
}
const ci = this?.referenceCIIdMap?.[this?.attr?.reference_type_id]?.[id]
if (!ci) {
return id
}
const attrName = this.referenceShowAttrNameMap?.[this?.attr.reference_type_id]
return ci?.[attrName] || id
},
}
}
</script>
<style lang="less" scoped>
.attr-display {
width: 100%;
font-size: 14px;
font-weight: 400;
word-break: break-all;
&-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
}
}
</style>

View File

@@ -0,0 +1,235 @@
<template>
<a-popover
v-model="visible"
trigger="click"
placement="bottom"
@visibleChange="handleVisibleChange"
>
<div class="filter-btn">
<a-icon class="filter-btn-icon" type="filter" />
<span class="filter-btn-title">{{ $t('cmdb.ciType.advancedFilter') }}</span>
</div>
<template slot="content">
<div class="filter-content">
<a-form :form="form">
<a-form-item
:label="$t('cmdb.ciType.ciType')"
:label-col="formLayout.labelCol"
:wrapper-col="formLayout.wrapperCol"
>
<treeselect
:value="selectCITypeIds"
class="custom-treeselect custom-treeselect-bgcAndBorder filter-content-ciTypes"
:style="{
width: '400px',
zIndex: '1000',
'--custom-height': '32px',
'--custom-bg-color': '#FFF',
'--custom-border': '1px solid #d9d9d9',
'--custom-multiple-lineHeight': '32px',
}"
:multiple="true"
:clearable="true"
searchable
:options="CITypeGroup"
:limit="1"
:limitText="(count) => `+ ${count}`"
value-consists-of="LEAF_PRIORITY"
:placeholder="$t('cmdb.ciType.ciType')"
@close="closeCiTypeGroup"
@open="openCiTypeGroup"
@input="inputCiTypeGroup"
:normalizer="
(node) => {
return {
id: node.id || -1,
label: node.alias || node.name || $t('other'),
title: node.alias || node.name || $t('other'),
children: node.ci_types,
}
}
"
>
<div
:title="node.label"
slot="option-label"
slot-scope="{ node }"
:style="{ width: '100%', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' }"
>
{{ node.label }}
</div>
</treeselect>
</a-form-item>
<a-form-item
:label="$t('cmdb.ciType.filterPopoverLabel')"
:label-col="formLayout.labelCol"
:wrapper-col="formLayout.wrapperCol"
class="filter-content-condition-filter"
>
<ConditionFilter
ref="conditionFilterRef"
:canSearchPreferenceAttrList="allAttributesList"
:expression="expression"
:CITypeIds="selectCITypeIds"
:isDropdown="false"
@setExpFromFilter="setExpFromFilter"
/>
</a-form-item>
</a-form>
<div class="filter-content-action">
<a-button
size="small"
@click="saveCondition(false)"
>
{{ $t('cmdb.ciType.saveCondition') }}
</a-button>
<a-button
type="primary"
size="small"
@click="saveCondition(true)"
>
{{ $t('confirm') }}
</a-button>
</div>
</div>
</template>
</a-popover>
</template>
<script>
import _ from 'lodash'
import ConditionFilter from '@/modules/cmdb/components/conditionFilter/index.vue'
export default {
name: 'FilterPopover',
components: {
ConditionFilter
},
data() {
return {
visible: false,
form: this.$form.createForm(this),
formLayout: {
labelCol: { span: 3 },
wrapperCol: { span: 15 },
},
lastCiType: [],
}
},
props: {
expression: {
type: String,
default: ''
},
selectCITypeIds: {
type: Array,
default: () => []
},
CITypeGroup: {
type: Array,
default: () => []
},
allAttributesList: {
type: Array,
default: () => []
}
},
methods: {
handleVisibleChange(open) {
if (open) {
this.$nextTick(() => {
this.$refs.conditionFilterRef.init(true, false)
})
}
},
openCiTypeGroup() {
this.lastCiType = _.cloneDeep(this.selectCITypeIds)
},
closeCiTypeGroup(value) {
if (!_.isEqual(value, this.lastCiType)) {
this.$emit('updateAllAttributesList', value)
}
},
inputCiTypeGroup(value) {
if (!value || !value.length) {
this.$emit('updateAllAttributesList', value)
}
this.$emit('changeFilter', {
name: 'selectCITypeIds',
value
})
},
setExpFromFilter(filterExp) {
const regSort = /(?<=sort=).+/g
const expSort = this.expression.match(regSort) ? this.expression.match(regSort)[0] : undefined
let expression = ''
if (filterExp) {
expression = `q=${filterExp}`
}
if (expSort) {
expression += `&sort=${expSort}`
}
this.$emit('changeFilter', {
name: 'expression',
value: expression
})
},
saveCondition(isSubmit) {
this.$refs.conditionFilterRef.handleSubmit()
this.$nextTick(() => {
this.$emit('saveCondition', isSubmit)
this.visible = false
})
},
}
}
</script>
<style lang="less" scoped>
.filter-btn {
flex-shrink: 0;
display: flex;
align-items: center;
margin-left: 13px;
cursor: pointer;
&-icon {
color: #2F54EB;
font-size: 12px;
}
&-title {
font-size: 14px;
font-weight: 400;
color: #2F54EB;
margin-left: 3px;
}
}
.filter-content {
width: 600px;
&-ciTypes {
/deep/ .vue-treeselect__value-container {
line-height: 32px;
}
}
&-condition-filter {
max-height: 250px;
// overflow-y: auto;
margin-bottom: 0px;
}
&-action {
width: 100%;
margin-top: 12px;
display: flex;
justify-content: flex-end;
align-items: center;
column-gap: 21px;
}
}
</style>

View File

@@ -0,0 +1,310 @@
<template>
<div class="history-list">
<div
class="history-recent"
v-if="recentList.length"
>
<div class="history-title">
<a-icon type="eye" class="history-title-icon" />
<div class="history-title-text">{{ $t('cmdb.ciType.recentSearch') }}</div>
<a-popconfirm
:title="$t('cmdb.ciType.confirmClear')"
placement="topRight"
@confirm="clearRecent"
>
<a-tooltip :title="$t('clear')" >
<a-icon
type="delete"
class="history-title-clear"
/>
</a-tooltip>
</a-popconfirm>
</div>
<div class="recent-list">
<div
v-for="(item) in recentList.slice(0, 10)"
:key="item.id"
class="recent-list-item"
@click="clickRecent(item.option)"
>
<div class="recent-list-item-text">
{{ getRecentSearchText(item.option) }}
</div>
<a-icon
type="close"
class="recent-list-item-close"
@click.stop="deleteRecent(item.id)"
/>
</div>
</div>
</div>
<div
class="history-favor"
v-if="favorList.length"
>
<div class="history-title">
<ops-icon type="veops-collect" class="history-title-icon" />
<div class="history-title-text">{{ $t('cmdb.ciType.myCollection') }}</div>
<div class="history-title-count">({{ favorList.length }})</div>
<ops-icon
type="veops-expand"
class="history-title-expand"
:style="{
transform: `rotate(${isExpand ? '180deg' : '0deg'})`
}"
@click="isExpand = !isExpand"
/>
</div>
<div
class="favor-list"
:style="{ height: isExpand ? 'auto' : '30px' }"
>
<div
v-for="(item) in favorList"
:key="item.id"
:class="['favor-list-item', detailCIId === item.option.CIId ? 'favor-list-item-selected' : '']"
@click="showDetail(item.option)"
>
<CIIcon
:icon="item.option.icon"
:title="item.option.CITypeTitle"
/>
<div class="favor-list-item-title">
{{ item.option.title }}
</div>
<ops-icon
type="veops-collected"
class="favor-list-item-collected"
@click.stop="deleteCollect(item.id)"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import CIIcon from '@/modules/cmdb/components/ciIcon/index.vue'
export default {
name: 'HistoryList',
components: {
CIIcon
},
props: {
recentList: {
type: Array,
default: () => []
},
favorList: {
type: Array,
default: () => []
},
detailCIId: {
type: [String, Number],
default: -1
}
},
data() {
return {
isExpand: false,
}
},
methods: {
getRecentSearchText(option) {
const textArray = []
if (option.searchValue) {
textArray.push(`${this.$t('cmdb.ciType.keyword')}: ${option.searchValue}`)
}
if (option?.ciTypeNames?.length) {
textArray.push(`${this.$t('cmdb.ciType.CIType')}: ${option.ciTypeNames.join(',')}`)
}
if (option.expression) {
textArray.push(`${this.$t('cmdb.ciType.conditionFilter')}: ${option.expression}`)
}
return textArray.join('; ')
},
clickRecent(data) {
this.$emit('clickRecent', data)
},
deleteRecent(id) {
this.$emit('deleteRecent', id)
},
deleteCollect(id) {
this.$emit('deleteCollect', id)
},
showDetail(data) {
this.$emit('showDetail', {
id: data.CIId,
ciTypeId: data.CITypeId
})
},
clearRecent() {
this.$emit('clearRecent')
}
}
}
</script>
<style lang="less" scoped>
.history-list {
width: 100%;
.history-title {
display: flex;
align-items: center;
&-icon {
font-size: 12px;
color: #2F54EB;
}
&-text {
font-size: 14px;
font-weight: 400;
color: #4E5969;
margin-left: 4px;
}
&-count {
font-size: 14px;
font-weight: 400;
color: #86909C;
}
&-clear {
margin-left: auto;
cursor: pointer;
}
&-expand {
margin-left: auto;
cursor: pointer;
}
}
.history-recent {
width: 100%;
margin-top: 15px;
.recent-list {
margin-top: 10px;
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: 16px;
row-gap: 8px;
&-item {
flex-shrink: 0;
padding: 4px 13px;
display: flex;
align-items: center;
border-radius: 22px;
background: rgba(255, 255, 255, 0.50);
cursor: pointer;
max-width: calc((100% - 16px) / 2);
&-text {
font-size: 12px;
font-weight: 400;
color: #1D2129;
max-width: 100%;
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
&-close {
font-size: 12px;
margin-left: 4px;
color: #A5A9BC;
display: none;
}
&:hover {
.recent-list-item-text {
color: #2F54EB;
}
.recent-list-item-close {
display: inline-block;
}
}
}
}
}
.history-favor {
width: 100%;
margin-top: 15px;
.favor-list {
margin-top: 10px;
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: 16px;
row-gap: 8px;
overflow: hidden;
min-height: 30px;
&-item {
flex-shrink: 0;
padding: 4px 13px;
display: flex;
align-items: center;
border-radius: 22px;
background: rgba(255, 255, 255, 0.90);
cursor: pointer;
max-width: calc((100% - 16px) / 2);
&-title {
font-size: 12px;
font-weight: 400;
margin-left: 4px;
color: #1D2129;
max-width: 100%;
text-overflow: ellipsis;
text-wrap: nowrap;
overflow: hidden;
}
&-collected {
font-size: 14px;
margin-left: 4px;
color: #FAD337;
}
&-selected {
border: 1px solid #7F97FA;
background-color: rgba(255, 255, 255, 0.90);
.favor-list-item-title {
color: #2F54EB;
}
}
&:hover {
.favor-list-item-title {
color: #2F54EB;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,438 @@
<template>
<div class="instance-detail">
<div
class="instance-detail-hide"
@click="hideDetail"
>
<a-icon class="instance-detail-hide-icon" type="right" />
</div>
<div class="instance-detail-null" v-if="!ci._id" >
<img
:src="require('@/modules/cmdb/assets/no_permission.png')"
class="instance-detail-null-img"
/>
<span class="instance-detail-null-text" >{{ $t('noData') }}</span>
</div>
<template v-else>
<div
class="instance-detail-header"
>
<div class="instance-detail-header-line-1"></div>
<div class="instance-detail-header-line-2"></div>
<div class="instance-detail-header-row">
<CIIcon
:icon="ciType.icon"
:title="ciType.name || ''"
:size="20"
/>
<div class="instance-detail-header-title">
{{ detailTitle }}
</div>
<ops-icon
:type="favorId ? 'veops-collected' : 'veops-collect'"
:style="{ color: favorId ? '#FAD337' : '#A5A9BC' }"
class="instance-detail-header-collect"
@click="clickCollect"
/>
<a class="instance-detail-header-share" @click="shareCi">
<a-icon type="share-alt" />
{{ $t('cmdb.ci.share') }}
</a>
</div>
</div>
<div class="instance-detail-attr">
<div
v-for="(group) in attributeGroups"
:key="group.id"
class="instance-detail-attr-group"
>
<span class="instance-detail-attr-group-name">{{ group.name || $t('other') }}</span>
<div class="instance-detail-attr-list">
<div
v-for="(attr) in group.attributes"
:key="attr.id"
class="instance-detail-attr-item"
>
<a-tooltip :title="attr.alias || attr.name || ''">
<div class="instance-detail-attr-item-label">
<span class="instance-detail-attr-item-label-text">
{{ attr.alias || attr.name || '' }}
</span>
<span class="instance-detail-attr-item-label-colon">:</span>
</div>
</a-tooltip>
<div class="instance-detail-attr-item-value">
<AttrDisplay
:attr="attr"
:ci="ci"
/>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<script>
import _ from 'lodash'
import { getCIById, searchCI } from '@/modules/cmdb/api/ci'
import { getCITypeGroupById, getCITypes, getCIType } from '@/modules/cmdb/api/CIType'
import AttrDisplay from './attrDisplay.vue'
import CIIcon from '@/modules/cmdb/components/ciIcon/index.vue'
export default {
name: 'InstanceDetail',
components: {
AttrDisplay,
CIIcon
},
props: {
CIId: {
type: [String, Number],
default: -1
},
CITypeId: {
type: [String, Number],
default: -1
},
favorList: {
type: Array,
default: () => []
}
},
data() {
return {
ci: {},
ciType: {},
attributeGroups: [],
isNullData: false,
}
},
computed: {
watchParams() {
return {
CIId: this.CIId,
CITypeId: this.CITypeId
}
},
detailTitle() {
const attrName = this?.ciType?.show_name || this?.ciType?.unique_name || ''
return attrName ? (this?.ci?.[attrName] || '') : ''
},
favorId() {
const id = this.favorList.find((item) => item?.option?.CIId === this.CIId)?.id
return id ?? null
}
},
watch: {
watchParams: {
immediate: true,
deep: true,
handler(newVal) {
if (newVal?.CIId !== -1 && newVal?.CITypeId !== -1) {
this.initData()
}
}
}
},
methods: {
async initData() {
const ci = await this.getCI()
if (!ci) {
this.isNullData = true
return
}
await this.getCIType()
await this.getAttributes()
},
async getCI() {
const res = await getCIById(this.CIId)
const ci = res.result?.[0] || {}
this.ci = ci
return ci
},
async getCIType() {
const res = await getCIType(this.CITypeId)
this.ciType = res?.ci_types?.[0] || {}
},
async getAttributes() {
const res = await getCITypeGroupById(this.CITypeId, { need_other: 1 })
this.attributeGroups = res
this.handleReferenceAttr()
},
async handleReferenceAttr() {
const map = {}
this.attributeGroups.forEach((group) => {
group.attributes.forEach((attr) => {
if (attr?.is_reference && attr?.reference_type_id && this.ci[attr.name]) {
const ids = Array.isArray(this.ci[attr.name]) ? this.ci[attr.name] : this.ci[attr.name] ? [this.ci[attr.name]] : []
if (ids.length) {
if (!map?.[attr.reference_type_id]) {
map[attr.reference_type_id] = {}
}
ids.forEach((id) => {
map[attr.reference_type_id][id] = {}
})
}
}
})
})
if (!Object.keys(map).length) {
return
}
const ciTypesRes = await getCITypes({
type_ids: Object.keys(map).join(',')
})
const showAttrNameMap = {}
ciTypesRes.ci_types.forEach((ciType) => {
showAttrNameMap[ciType.id] = ciType?.show_name || ciType?.unique_name || ''
})
const allRes = await Promise.all(
Object.keys(map).map((key) => {
return searchCI({
q: `_type:${key},_id:(${Object.keys(map[key]).join(';')})`,
count: 9999
})
})
)
const ciNameMap = {}
allRes.forEach((res) => {
res.result.forEach((item) => {
ciNameMap[item._id] = item
})
})
const newAttrGroups = _.cloneDeep(this.attributeGroups)
newAttrGroups.forEach((group) => {
group.attributes.forEach((attr) => {
if (attr?.is_reference && attr?.reference_type_id) {
attr.showAttrName = showAttrNameMap?.[attr?.reference_type_id] || ''
const referenceShowAttrNameMap = {}
const referenceCIIds = this.ci[attr.name];
(Array.isArray(referenceCIIds) ? referenceCIIds : referenceCIIds ? [referenceCIIds] : []).forEach((id) => {
referenceShowAttrNameMap[id] = ciNameMap?.[id]?.[attr.showAttrName] ?? id
})
attr.referenceShowAttrNameMap = referenceShowAttrNameMap
}
})
})
this.$set(this, 'attributeGroups', newAttrGroups)
},
shareCi() {
const text = `${document.location.host}/cmdb/cidetail/${this.CITypeId}/${this.CIId}`
this.$copyText(text)
.then(() => {
this.$message.success(this.$t('copySuccess'))
})
.catch(() => {
this.$message.error(this.$t('cmdb.ci.copyFailed'))
})
},
clickCollect() {
if (this.favorId) {
this.$emit('deleteCollect', this.favorId)
} else {
this.$emit('addCollect', {
CIId: this.CIId,
CITypeId: this.CITypeId,
title: this.detailTitle,
icon: this.ciType?.icon,
CITypeTitle: this.ciType?.name || ''
})
}
},
hideDetail() {
this.$emit('hideDetail')
}
}
}
</script>
<style lang="less" scoped>
.instance-detail {
width: 100%;
height: 100%;
border-radius: 2px;
border: 1px solid #E4E7ED;
background-color: #FFFFFF;
display: flex;
flex-direction: column;
position: relative;
&-hide {
position: absolute;
left: 0;
top: 50%;
margin-top: -21px;
border-radius: 0px 2px 2px 0px;
background-color: #2f54eb;
width: 13px;
height: 43px;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
cursor: pointer;
&-icon {
color: #FFFFFF;
font-size: 12px;
}
&:hover {
background-color: #597ef7;
}
}
&-null {
display: flex;
flex-direction: column;
width: 100%;
align-items: center;
padding-top: 100px;
&-img {
width: 180px;
}
&-text {
color: #86909C;
margin-top: 20px;
}
}
&-header {
width: 100%;
height: 75px;
background-color: #EBF0F9;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
padding: 0 20px;
flex-shrink: 0;
&-line-1 {
height: 44px;
width: 300px;
position: absolute;
right: -20px;
top: 0px;
transform: rotate(40deg);
background: rgba(248, 249, 253, 0.60);
}
&-line-2 {
height: 44px;
width: 300px;
position: absolute;
right: -110px;
top: 0px;
transform: rotate(40deg);
background: rgba(248, 249, 253, 0.60);
}
&-row {
width: 100%;
height: 100%;
display: flex;
align-items: center;
position: relative;
z-index: 2;
}
&-title {
font-size: 16px;
font-weight: 700;
color: #1D2129;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
margin-left: 9px;
}
&-collect {
margin-left: 8px;
margin-right: 8px;
}
&-share {
margin-left: auto;
flex-shrink: 0;
}
}
&-attr {
width: 100%;
overflow-y: auto;
height: 100%;
padding: 20px;
&-group {
&:not(:first-child) {
margin-top: 15px;
}
&-name {
font-size: 14px;
font-weight: 700;
color: #1D2129;
}
}
&-item {
margin-top: 15px;
display: flex;
align-items: flex-start;
&-label {
font-size: 14px;
font-weight: 400;
color: #86909C;
width: 25%;
flex-shrink: 0;
display: flex;
align-items: center;
&-text {
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
}
&-colon {
flex-shrink: 0;
}
}
&-value {
margin-left: 12px;
}
}
}
}
</style>

View File

@@ -0,0 +1,443 @@
<template>
<div class="list-wrap">
<div class="list-wrap-bg" v-if="!filterList.length">
<img :src="require('@/modules/cmdb/assets/resourceSearch/resource_search_bg_2.png')" />
</div>
<div v-if="tabList.length" class="list-tab">
<div class="list-tab-left">
<div class="list-tab-label">{{ $t('cmdb.ciType.currentPage') }}: </div>
<div
v-for="(tab) in tabList"
:key="tab.id"
:class="['list-tab-item', tab.id === currentTab ? 'list-tab-item-active' : '']"
@click="clickTab(tab.id)"
>
<span class="list-tab-item-title">{{ tab.title }}</span>
(<span class="list-tab-item-count">{{ tab.count }}</span>)
</div>
</div>
<a-button
icon="download"
type="primary"
class="ops-button-ghost list-tab-export"
ghost
@click="handleExport"
>
{{ $t('download') }}
</a-button>
</div>
<div v-if="filterList.length" class="list-container">
<div
v-for="(item) in filterList"
:key="item._id"
:class="['list-card', detailCIId === item.ci._id ? 'list-card-selected' : '']"
@click="clickInstance(item.ci._id, item.ciTypeObj.id)"
>
<div class="list-card-header">
<div class="list-card-model">
<CIIcon
:icon="item.ciTypeObj.icon"
:title="item.ciTypeObj.name"
/>
<span class="list-card-model-title">{{ item.ciTypeObj.title }}</span>
</div>
<div class="list-card-title">{{ item.ci[item.ciTypeObj.showAttrName] }}</div>
<ops-icon
v-if="getFavorId(item.ci._id)"
type="veops-collected"
class="list-card-collect"
:style="{ color: '#FAD337' }"
@click.stop="deleteCollect(item.ci._id)"
/>
<ops-icon
v-else
type="veops-collect"
class="list-card-collect"
:style="{ color: '#A5A9BC' }"
@click.stop="addCollect(item)"
/>
</div>
<div class="list-card-attr">
<div
v-for="(attr) in item.attributes"
:key="attr.name"
class="list-card-attr-item"
>
<div class="list-card-attr-item-label">{{ attr.alias || attr.name || '' }}: </div>
<div class="list-card-attr-item-value">
<AttrDisplay
:attr="attr"
:ci="item.ci"
:referenceShowAttrNameMap="referenceShowAttrNameMap"
:referenceCIIdMap="referenceCIIdMap"
:isEllipsis="true"
:searchValue="searchValue"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import ExcelJS from 'exceljs'
import FileSaver from 'file-saver'
import moment from 'moment'
import AttrDisplay from './attrDisplay.vue'
import CIIcon from '@/modules/cmdb/components/ciIcon/index.vue'
export default {
name: 'InstanceList',
components: {
AttrDisplay,
CIIcon
},
props: {
list: {
type: Array,
default: () => []
},
tabList: {
type: Array,
default: () => []
},
referenceShowAttrNameMap: {
type: Object,
default: () => {}
},
referenceCIIdMap: {
type: Object,
default: () => {}
},
favorList: {
type: Array,
default: () => []
},
detailCIId: {
type: [String, Number],
default: -1
},
searchValue: {
type: String,
default: ''
}
},
data() {
return {
currentTab: ''
}
},
computed: {
filterList() {
if (!this.currentTab || this.currentTab === -1) {
return this.list
}
return this.list.filter((item) => item.ciTypeObj.id === this.currentTab)
}
},
watch: {
tabList: {
immediate: true,
deep: true,
handler(newVal) {
this.currentTab = newVal?.[0]?.id ?? ''
}
}
},
methods: {
clickTab(id) {
this.currentTab = id
},
getAttrLabel(attrName, attributes) {
const label = attributes.find((attr) => attr.name === attrName)?.alias
return label || attrName
},
clickInstance(id, ciTypeId) {
this.$emit('showDetail', {
id,
ciTypeId,
})
},
getFavorId(ciId) {
const id = this.favorList.find((item) => item?.option?.CIId === ciId)?.id
return id ?? null
},
addCollect(data) {
this.$emit('addCollect', {
CIId: data.ci._id,
CITypeId: data.ciTypeObj.id,
title: data.ci[data.ciTypeObj.showAttrName],
icon: data.ciTypeObj.icon,
CITypeTitle: data.ciTypeObj.name
})
},
deleteCollect(ciId) {
const favorId = this.getFavorId(ciId)
if (favorId) {
this.$emit('deleteCollect', favorId)
}
},
handleExport() {
const excel_name = `cmdb-${this.$t('cmdb.ciType.resourceSearch')}-${moment().format('YYYYMMDDHHmmss')}.xlsx`
const wb = new ExcelJS.Workbook()
this.tabList.map((sheet) => {
if (sheet.id === -1) {
return
}
const ws = wb.addWorksheet(sheet.title)
this.handleSheetData({
ws,
sheet
})
})
wb.xlsx.writeBuffer().then((buffer) => {
const file = new Blob([buffer], {
type: 'application/octet-stream',
})
FileSaver.saveAs(file, excel_name)
})
},
handleSheetData({
ws,
sheet
}) {
const listData = this.list.filter((item) => item.ciTypeObj.id === sheet.id)
if (!listData.length) {
return
}
const columnMap = new Map()
const columns = listData[0].attributes.filter((attr) => !attr.is_password).map((attr) => {
columnMap.set(attr.name, attr)
return {
header: attr.alias || attr.name || '',
key: attr.name,
width: 20,
}
})
ws.columns = columns
listData.forEach((data) => {
const row = {}
columns.forEach(({ key }) => {
const value = data?.ci?.[key] ?? null
const attr = columnMap.get(key)
if (attr.valueType === '6') {
row[key] = value ? JSON.stringify(value) : value
} else if (attr.is_list && Array.isArray(value)) {
row[key] = value.join(',')
} else {
row[key] = value
}
})
ws.addRow(row)
})
}
}
}
</script>
<style lang="less" scoped>
.list-wrap {
width: 100%;
height: 100%;
flex-shrink: 1 !important;
overflow: hidden;
display: flex;
flex-direction: column;
&-bg {
width: 100%;
padding-top: 90px;
display: flex;
justify-content: center;
img {
width: 300px;
}
}
.list-tab {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
column-gap: 14px;
&-left {
display: flex;
align-items: center;
column-gap: 14px;
row-gap: 7px;
overflow-x: auto;
max-width: 100%;
}
&-label {
font-size: 14px;
font-weight: 400;
color: #4E5969;
flex-shrink: 0;
}
&-item {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 400;
color: #4E5969;
cursor: pointer;
flex-shrink: 0;
&-count {
color: #2F54EB;
}
&-active {
color: #2F54EB;
}
&:hover {
color: #2F54EB;
}
}
&-export {
margin-left: auto;
flex-shrink: 0;
}
}
.list-container {
width: 100%;
margin-top: 12px;
height: 100%;
overflow-y: auto;
flex-shrink: 1;
flex-grow: 0;
.list-card {
width: 100%;
background-color: #FFF;
border-radius: 4px;
padding: 15px;
cursor: pointer;
&:not(:first-child) {
margin-top: 16px;
}
&-selected {
border: 1px solid #7F97FA;
background-color: #F9FBFF;
}
&-header {
display: flex;
align-items: center;
}
&-model {
border-radius: 24px;
border: 1px solid #E4E7ED;
background-color: #FFF;
display: flex;
align-items: center;
justify-content: center;
height: 24px;
padding: 0 13px;
flex-shrink: 0;
&-title {
font-size: 12px;
font-weight: 400;
line-height: 24px;
color: #1D2129;
margin-left: 4px;
}
}
&-title {
margin-left: 11px;
font-size: 14px;
font-weight: 700;
color: #1D2129;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
}
&-collect {
font-size: 12px;
margin-left: 9px;
display: none;
}
&-attr {
display: flex;
flex-wrap: wrap;
align-items: center;
overflow: hidden;
height: 25px;
column-gap: 40px;
row-gap: 20px;
margin-top: 12px;
&-item {
flex-shrink: 0;
max-width: calc((100% - 160px) / 5);
display: flex;
align-items: center;
overflow: hidden;
&-label {
color: #86909C;
font-size: 14px;
font-weight: 400;
flex-shrink: 0;
}
&-value {
color: #1D2129;
font-size: 14px;
font-weight: 400;
margin-left: 12px;
overflow: hidden;
}
}
}
&:hover {
box-shadow: 0px 2px 12px 0px rgba(147, 168, 223, 0.20);
.list-card-collect {
display: inline-block;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,201 @@
<template>
<div :class="['search-input', classType ? 'search-input-' + classType : '']">
<a-input
:value="searchValue"
class="search-input-component"
:placeholder="$t('cmdb.ciType.searchInputTip')"
@change="handleChangeSearchValue"
@pressEnter="saveCondition(true)"
>
<a-icon
class="search-input-component-icon"
slot="prefix"
type="search"
@click="saveCondition(true)"
/>
</a-input>
<FilterPopover
ref="filterPpoverRef"
:CITypeGroup="CITypeGroup"
:allAttributesList="allAttributesList"
:expression="expression"
:selectCITypeIds="selectCITypeIds"
@changeFilter="changeFilter"
@updateAllAttributesList="updateAllAttributesList"
@saveCondition="saveCondition"
/>
<div v-if="copyText" class="expression-display">
<span class="expression-display-text">{{ copyText }}</span>
<a-icon
slot="suffix"
type="check-circle"
class="expression-display-icon"
@click="handleCopyExpression"
/>
</div>
</div>
</template>
<script>
import FilterPopover from './filterPopover.vue'
export default {
name: 'SearchInput',
components: {
FilterPopover
},
props: {
searchValue: {
type: String,
default: ''
},
expression: {
type: String,
default: ''
},
selectCITypeIds: {
type: Array,
default: () => []
},
CITypeGroup: {
type: Array,
default: () => []
},
allAttributesList: {
type: Array,
default: () => []
},
classType: {
type: String,
default: ''
}
},
data() {
return {}
},
computed: {
// 复制文字展示与实际文本复制内容区别在于未选择模型时不展示所有模型拼接数据
copyText() {
const regQ = /(?<=q=).+(?=&)|(?<=q=).+$/g
const exp = this.expression.match(regQ) ? this.expression.match(regQ)[0] : null
const textArray = []
if (this.selectCITypeIds?.length) {
textArray.push(`_type:(${this.selectCITypeIds.join(';')})`)
}
if (exp) {
textArray.push(exp)
}
if (this.searchValue) {
textArray.push(`*${this.searchValue}*`)
}
return textArray.length ? `q=${textArray.join(',')}` : ''
},
},
methods: {
updateAllAttributesList(value) {
this.$emit('updateAllAttributesList', value)
},
saveCondition(isSubmit) {
this.$emit('saveCondition', isSubmit)
},
handleChangeSearchValue(e) {
const value = e.target.value
this.changeFilter({
name: 'searchValue',
value
})
},
changeFilter(data) {
this.$emit('changeFilter', data)
},
handleCopyExpression() {
const { selectCITypeIds, expression, searchValue } = this
const regQ = /(?<=q=).+(?=&)|(?<=q=).+$/g
const exp = expression.match(regQ) ? expression.match(regQ)[0] : null
const ciTypeIds = [...selectCITypeIds]
if (!ciTypeIds.length) {
this.CITypeGroup.forEach((item) => {
const ids = item.ci_types.map((ci_type) => ci_type.id)
ciTypeIds.push(...ids)
})
}
const copyText = `${ciTypeIds?.length ? `_type:(${ciTypeIds.join(';')})` : ''}${exp ? `,${exp}` : ''}${searchValue ? `,*${searchValue}*` : ''}`
this.$copyText(copyText)
.then(() => {
this.$message.success(this.$t('copySuccess'))
})
.catch(() => {
this.$message.error(this.$t('cmdb.ci.copyFailed'))
})
}
}
}
</script>
<style lang="less" scoped>
.search-input {
width: 100%;
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
&-component {
height: 100%;
flex-grow: 1;
background-color: #FFFFFF;
border: none;
font-size: 14px;
border-radius: 48px;
overflow: hidden;
&-icon {
color: #2F54EB;
font-size: 14px;
}
/deep/ & > input {
height: 100%;
margin-left: 10px;
border: none;
box-shadow: none;
}
}
&-after {
height: 38px;
justify-content: flex-start;
.search-input-component {
max-width: 524px;
}
}
.expression-display {
display: flex;
align-items: center;
margin-left: 20px;
max-width: 30%;
&-text {
width: 100%;
text-overflow: ellipsis;
overflow: hidden;
text-wrap: nowrap;
}
&-icon {
margin-left: 8px;
color: #00b42a;
cursor: pointer;
}
}
}
</style>

View File

@@ -0,0 +1,625 @@
<template>
<div
class="resource-search"
:style="{ height: `${windowHeight - 131}px` }"
>
<div v-if="!isSearch" class="resource-search-before">
<div class="resource-search-title">
<ops-icon class="resource-search-title-icon" type="veops-resource11" />
<span class="resource-search-title-text">{{ $t('cmdb.ciType.resourceSearch') }}</span>
</div>
<SearchInput
ref="searchInputRef"
:CITypeGroup="CITypeGroup"
:allAttributesList="allAttributesList"
:searchValue="searchValue"
:selectCITypeIds="selectCITypeIds"
:expression="expression"
@changeFilter="changeFilter"
@updateAllAttributesList="updateAllAttributesList"
@saveCondition="saveCondition"
/>
<HistoryList
:recentList="recentList"
:favorList="favorList"
:detailCIId="detailCIId"
@clickRecent="clickRecent"
@deleteRecent="deleteRecent"
@clearRecent="clearRecent"
@deleteCollect="deleteCollect"
@showDetail="clickFavor"
/>
<img class="resource-search-before-bg" :src="require('@/modules/cmdb/assets/resourceSearch/resource_search_bg_1.png')" />
</div>
<div class="resource-search-after" v-else>
<div
class="resource-search-after-left"
:style="{ width: showInstanceDetail ? '70%' : '100%' }"
>
<SearchInput
ref="searchInputRef"
classType="after"
:CITypeGroup="CITypeGroup"
:allAttributesList="allAttributesList"
:searchValue="searchValue"
:selectCITypeIds="selectCITypeIds"
:expression="expression"
@changeFilter="changeFilter"
@updateAllAttributesList="updateAllAttributesList"
@saveCondition="saveCondition"
/>
<HistoryList
:recentList="recentList"
:favorList="favorList"
:detailCIId="detailCIId"
@clickRecent="clickRecent"
@deleteRecent="deleteRecent"
@clearRecent="clearRecent"
@deleteCollect="deleteCollect"
@showDetail="clickFavor"
/>
<div class="resource-search-divider"></div>
<InstanceList
:list="instanceList"
:tabList="ciTabList"
:referenceShowAttrNameMap="referenceShowAttrNameMap"
:referenceCIIdMap="referenceCIIdMap"
:favorList="favorList"
:detailCIId="detailCIId"
:searchValue="currentSearchValue"
@showDetail="showDetail"
@addCollect="addCollect"
@deleteCollect="deleteCollect"
/>
<div class="resource-search-pagination">
<a-pagination
:showSizeChanger="true"
:current="currentPage"
size="small"
:total="totalNumber"
show-quick-jumper
:page-size="pageSize"
:page-size-options="pageSizeOptions"
@showSizeChange="handlePageSizeChange"
:show-total="
(total, range) =>
$t('pagination.total', {
range0: range[0],
range1: range[1],
total,
})
"
@change="changePage"
>
<template slot="buildOptionText" slot-scope="props">
<span v-if="props.value !== '100000'">{{ props.value }}{{ $t('itemsPerPage') }}</span>
<span v-if="props.value === '100000'">{{ $t('all') }}</span>
</template>
</a-pagination>
</div>
</div>
<div v-if="showInstanceDetail" class="resource-search-after-right">
<InstanceDetail
:CIId="detailCIId"
:CITypeId="detailCITypeId"
:favorList="favorList"
@addCollect="addCollect"
@deleteCollect="deleteCollect"
@hideDetail="hideDetail"
/>
</div>
</div>
</div>
</template>
<script>
import _ from 'lodash'
import { mapState } from 'vuex'
import { getPreferenceSearch, savePreferenceSearch, getSubscribeAttributes, deletePreferenceSearch } from '@/modules/cmdb/api/preference'
import { searchAttributes, getCITypeAttributesByTypeIds } from '@/modules/cmdb/api/CITypeAttr'
import { searchCI } from '@/modules/cmdb/api/ci'
import { getCITypes } from '@/modules/cmdb/api/CIType'
import SearchInput from './components/searchInput.vue'
import HistoryList from './components/historyList.vue'
import InstanceList from './components/instanceList.vue'
import InstanceDetail from './components/instanceDetail.vue'
export default {
name: 'ResourceSearchCom',
components: {
SearchInput,
HistoryList,
InstanceList,
InstanceDetail
},
props: {
CITypeGroup: {
type: Array,
default: () => []
},
allCITypes: {
type: Array,
default: () => []
}
},
data() {
return {
// 筛选条件
searchValue: '', // 搜索框
selectCITypeIds: [], // 已选模型
expression: '', // 筛选语句
currentSearchValue: '', // 当前已搜索语句
recentList: [], // 最近搜索
favorList: [], // 我的收藏
allAttributesList: [],
isSearch: false, // 是否搜索过
currentPage: 1,
pageSizeOptions: ['50', '100', '200', '100000'],
pageSize: 50,
totalNumber: 0,
ciTabList: [],
instanceList: [],
referenceShowAttrNameMap: {},
referenceCIIdMap: {},
showInstanceDetail: false,
detailCIId: -1,
detailCITypeId: -1,
}
},
computed: {
...mapState({
cmdbSearchValue: (state) => state.app.cmdbSearchValue,
}),
windowHeight() {
return this.$store.state.windowHeight
},
},
watch: {
cmdbSearchValue: {
handler(value) {
this.searchValue = value
this.saveCondition(true)
}
}
},
mounted() {
this.initData()
},
methods: {
async initData() {
await this.getFavorList()
this.$nextTick(async () => {
if (this.cmdbSearchValue) {
this.searchValue = this.cmdbSearchValue
this.saveCondition(true)
} else {
await this.getRecentList()
}
})
await this.getAllAttr()
},
async getRecentList() {
const recentList = await getPreferenceSearch({
name: '__recent__'
})
recentList.sort((a, b) => b.id - a.id)
this.recentList = recentList
},
async getFavorList() {
const favorList = await getPreferenceSearch({
name: '__favor__'
})
favorList.sort((a, b) => b.id - a.id)
this.favorList = favorList
},
async getAllAttr() {
const res = await searchAttributes({ page_size: 9999 })
this.allAttributesList = res.attributes
this.originAllAttributesList = res.attributes
},
async updateAllAttributesList(value) {
if (value && value.length) {
const res = await getCITypeAttributesByTypeIds({ type_ids: value.join(',') })
this.allAttributesList = res.attributes
} else {
this.allAttributesList = this.originAllAttributesList
}
},
async saveCondition(isSubmit) {
if (
this.searchValue ||
this.expression ||
this.selectCITypeIds.length
) {
const needDeleteList = []
const differentList = []
this.recentList.forEach((item) => {
const option = item.option
if (
option.searchValue === this.searchValue &&
option.expression === this.expression &&
_.isEqual(option.ciTypeIds, this.selectCITypeIds)
) {
needDeleteList.push(item.id)
} else {
differentList.push(item.id)
}
})
if (differentList.length >= 10) {
needDeleteList.push(...differentList.slice(9))
}
if (needDeleteList.length) {
await Promise.all(
needDeleteList.map((id) => deletePreferenceSearch(id))
)
}
const ciTypeNames = this.selectCITypeIds.map((id) => {
const ciType = this.allCITypes.find((item) => item.id === id)
return ciType?.alias || ciType?.name || id
})
await savePreferenceSearch({
option: {
searchValue: this.searchValue,
expression: this.expression,
ciTypeIds: this.selectCITypeIds,
ciTypeNames
},
name: '__recent__'
})
this.getRecentList()
}
if (isSubmit) {
this.isSearch = true
this.currentPage = 1
this.hideDetail()
this.loadInstance()
}
},
async deleteRecent(id) {
await deletePreferenceSearch(id)
this.getRecentList()
},
async clearRecent() {
const deletePromises = this.recentList.map((item) => {
return deletePreferenceSearch(item.id)
})
await Promise.all(deletePromises)
this.getRecentList()
},
async loadInstance() {
const { selectCITypeIds, expression, searchValue } = this
const regQ = /(?<=q=).+(?=&)|(?<=q=).+$/g
const exp = expression.match(regQ) ? expression.match(regQ)[0] : null
const ciTypeIds = [...selectCITypeIds]
if (!ciTypeIds.length) {
this.CITypeGroup.forEach((item) => {
const ids = item.ci_types.map((ci_type) => ci_type.id)
ciTypeIds.push(...ids)
})
}
const res = await searchCI({
q: `${ciTypeIds?.length ? `_type:(${ciTypeIds.join(';')})` : ''}${exp ? `,${exp}` : ''}${
searchValue ? `,*${searchValue}*` : ''
}`,
count: this.pageSize,
page: this.currentPage,
sort: '_type'
})
this.currentSearchValue = searchValue
this.totalNumber = res?.numfound ?? 0
if (!res?.result?.length) {
this.ciTabList = []
this.instanceList = []
}
const ciTabMap = new Map()
let list = res.result
list.forEach((item) => {
const ciType = this.allCITypes.find((type) => type.id === item._type)
if (ciTabMap.has(item._type)) {
ciTabMap.get(item._type).count++
} else {
ciTabMap.set(item._type, {
id: item._type,
count: 1,
title: ciType?.alias || ciType?.name || '',
})
}
})
const mapEntries = [...ciTabMap.entries()]
const subscribedPromises = mapEntries.map((item) => {
return getSubscribeAttributes(item[0])
})
const subscribedRes = await Promise.all(subscribedPromises)
list = list.map((item) => {
const subscribedIndex = mapEntries.findIndex((mapValue) => mapValue[0] === item._type)
const subscribedAttr = subscribedRes?.[subscribedIndex]?.attributes || []
const obj = {
ci: item,
ciTypeObj: {},
attributes: subscribedAttr
}
const ciType = this.allCITypes.find((type) => type.id === item._type)
obj.ciTypeObj = {
showAttrName: ciType?.show_name || ciType?.unique_key || '',
icon: ciType?.icon || '',
title: ciType?.alias || ciType?.name || '',
name: ciType?.name || '',
id: ciType.id
}
return obj
})
this.instanceList = list
const ciTabList = [...ciTabMap.values()]
if (list?.length) {
ciTabList.unshift({
id: -1,
title: this.$t('all'),
count: list?.length
})
}
this.ciTabList = ciTabList
// 处理引用属性
const allAttr = []
subscribedRes.map((item) => {
allAttr.push(...item.attributes)
})
this.handlePerference(_.uniqBy(allAttr, 'id'))
},
handlePerference(allAttr) {
let needRequiredCIType = []
allAttr.forEach((attr) => {
if (attr?.is_reference && attr?.reference_type_id) {
needRequiredCIType.push(attr)
}
})
needRequiredCIType = _.uniq(needRequiredCIType, 'id')
if (!needRequiredCIType.length) {
this.referenceShowAttrNameMap = {}
this.referenceCIIdMap = {}
return
}
this.handleReferenceShowAttrName(needRequiredCIType)
this.handleReferenceCIIdMap(needRequiredCIType)
},
async handleReferenceShowAttrName(needRequiredCIType) {
const res = await getCITypes({
type_ids: needRequiredCIType.map((col) => col.reference_type_id).join(',')
})
const map = {}
res.ci_types.forEach((ciType) => {
map[ciType.id] = ciType?.show_name || ciType?.unique_name || ''
})
this.referenceShowAttrNameMap = map
},
async handleReferenceCIIdMap(needRequiredCIType) {
const map = {}
this.instanceList.forEach(({ ci }) => {
needRequiredCIType.forEach((col) => {
const ids = Array.isArray(ci[col.name]) ? ci[col.name] : ci[col.name] ? [ci[col.name]] : []
if (ids.length) {
if (!map?.[col.reference_type_id]) {
map[col.reference_type_id] = {}
}
ids.forEach((id) => {
map[col.reference_type_id][id] = {}
})
}
})
})
if (!Object.keys(map).length) {
this.referenceCIIdMap = {}
return
}
const allRes = await Promise.all(
Object.keys(map).map((key) => {
return searchCI({
q: `_type:${key},_id:(${Object.keys(map[key]).join(';')})`,
count: 9999
})
})
)
allRes.forEach((res) => {
res.result.forEach((item) => {
if (map?.[item._type]?.[item._id]) {
map[item._type][item._id] = item
}
})
})
this.referenceCIIdMap = map
},
clickRecent(data) {
this.updateAllAttributesList(data.ciTypeIds || [])
this.isSearch = true
this.currentPage = 1
this.searchValue = data?.searchValue || ''
this.expression = data?.expression || ''
this.selectCITypeIds = data?.ciTypeIds || []
this.hideDetail()
this.loadInstance()
},
handlePageSizeChange(_, pageSize) {
this.pageSize = pageSize
this.currentPage = 1
this.loadInstance()
},
changePage(page) {
this.currentPage = page
this.loadInstance()
},
changeFilter(data) {
this[data.name] = data.value
},
showDetail(data) {
this.detailCIId = data.id
this.detailCITypeId = data.ciTypeId
this.showInstanceDetail = true
},
hideDetail() {
this.detailCIId = -1
this.detailCITypeId = -1
this.showInstanceDetail = false
},
async addCollect(data) {
if (this?.favorList?.length >= 10) {
const deletePromises = this.favorList.slice(9).map((item) => {
return deletePreferenceSearch(item.id)
})
await Promise.all(deletePromises)
}
await savePreferenceSearch({
option: {
...data
},
name: '__favor__'
})
this.getFavorList()
},
async deleteCollect(id) {
await deletePreferenceSearch(id)
this.getFavorList()
},
clickFavor(data) {
this.isSearch = true
this.showDetail(data)
}
}
}
</script>
<style lang="less" scoped>
.resource-search {
width: 100%;
height: 100%;
position: relative;
&-before {
width: 100%;
max-width: 725px;
height: 100%;
margin: 0 auto;
padding-top: 100px;
display: flex;
flex-direction: column;
align-items: center;
& > div {
position: relative;
z-index: 1;
}
&-bg {
position: absolute;
left: -24px;
bottom: -24px;
width: calc(100% + 48px);
z-index: 0;
}
}
&-title {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 25px;
&-icon {
font-size: 28px;
}
&-text {
margin-left: 10px;
font-size: 20px;
font-weight: 700;
color: #1D2129;
}
}
&-after {
width: 100%;
height: 100%;
display: flex;
justify-content: space-between;
&-left {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
& > div {
flex-shrink: 0;
}
}
&-right {
margin-left: 20px;
width: calc(30% - 20px);
flex-shrink: 0;
}
}
&-divider {
width: 100%;
height: 1px;
background-color: #E4E7ED;
margin: 20px 0;
}
&-pagination {
text-align: right;
margin: 12px 0px;
}
}
</style>

View File

@@ -15,6 +15,7 @@
:paneLengthPixel.sync="paneLengthPixel"
appName="cmdb-topo-views"
:triggerLength="18"
calcBasedParent
>
<template #one>
<a-input

View File

@@ -10,6 +10,7 @@
:paneLengthPixel.sync="paneLengthPixel"
appName="cmdb-tree-views"
:triggerLength="18"
calcBasedParent
>
<template #one>
<div class="tree-views-left" :style="{ height: `${windowHeight - 64}px` }">

View File

@@ -25,7 +25,7 @@ const app = {
color: null,
weak: false,
multiTab: false,
cmdbSearchValue: '',
},
mutations: {
SET_SIDEBAR_TYPE: (state, type) => {
@@ -76,7 +76,9 @@ const app = {
Vue.ls.set(DEFAULT_MULTI_TAB, bool)
state.multiTab = bool
},
UPDATE_CMDB_SEARCH_VALUE: (state, value) => {
state.cmdbSearchValue = value
}
},
actions: {
setSidebar({ commit }, type) {
@@ -118,6 +120,9 @@ const app = {
ToggleMultiTab({ commit }, bool) {
commit('TOGGLE_MULTI_TAB', bool)
},
UpdateCMDBSEarchValue({ commit }, value) {
commit('UPDATE_CMDB_SEARCH_VALUE', value)
}
}
}

View File

@@ -1,106 +1,121 @@
<template>
<treeselect
:disable-branch-nodes="multiple ? false : true"
:multiple="multiple"
:options="employeeTreeSelectOption"
:placeholder="readOnly ? '' : placeholder || $t('cs.components.selectEmployee')"
v-model="treeValue"
:max-height="200"
:noChildrenText="$t('cs.components.empty')"
:noOptionsText="$t('cs.components.empty')"
:class="className ? className : 'ops-setting-treeselect'"
value-consists-of="LEAF_PRIORITY"
:limit="limit"
:limitText="(count) => `+ ${count}`"
v-bind="$attrs"
appendToBody
:zIndex="1050"
:flat="flat"
>
</treeselect>
</template>
<script>
import Treeselect from '@riophae/vue-treeselect'
import { formatOption } from '@/utils/util'
export default {
name: 'EmployeeTreeSelect',
components: {
Treeselect,
},
model: {
prop: 'value',
event: 'change',
},
props: {
value: {
type: [String, Array, Number, null],
default: null,
},
multiple: {
type: Boolean,
default: false,
},
className: {
type: String,
default: 'ops-setting-treeselect',
},
placeholder: {
type: String,
default: '',
},
idType: {
type: Number,
default: 1,
},
departmentKey: {
type: String,
default: 'department_id',
},
employeeKey: {
type: String,
default: 'employee_id',
},
limit: {
type: Number,
default: 20,
},
flat: {
type: Boolean,
default: false,
},
},
data() {
return {}
},
inject: {
provide_allTreeDepAndEmp: {
from: 'provide_allTreeDepAndEmp',
},
readOnly: {
from: 'readOnly',
default: false,
},
},
computed: {
treeValue: {
get() {
return this.value
},
set(val) {
this.$emit('change', val)
return val
},
},
allTreeDepAndEmp() {
return this.provide_allTreeDepAndEmp()
},
employeeTreeSelectOption() {
return formatOption(this.allTreeDepAndEmp, this.idType, false, this.departmentKey, this.employeeKey)
},
},
methods: {},
}
</script>
<style scoped></style>
<template>
<treeselect
:disable-branch-nodes="multiple ? false : true"
:multiple="multiple"
:options="employeeTreeSelectOption"
:placeholder="readOnly ? '' : placeholder || $t('cs.components.selectEmployee')"
v-model="treeValue"
:max-height="200"
:noChildrenText="$t('cs.components.empty')"
:noOptionsText="$t('cs.components.empty')"
:class="className ? className : 'ops-setting-treeselect'"
value-consists-of="LEAF_PRIORITY"
:limit="limit"
:limitText="(count) => `+ ${count}`"
v-bind="$attrs"
appendToBody
:zIndex="1050"
:flat="flat"
>
</treeselect>
</template>
<script>
import _ from 'lodash'
import Treeselect from '@riophae/vue-treeselect'
import { formatOption } from '@/utils/util'
export default {
name: 'EmployeeTreeSelect',
components: {
Treeselect,
},
model: {
prop: 'value',
event: 'change',
},
props: {
value: {
type: [String, Array, Number, null],
default: null,
},
multiple: {
type: Boolean,
default: false,
},
className: {
type: String,
default: 'ops-setting-treeselect',
},
placeholder: {
type: String,
default: '',
},
idType: {
type: Number,
default: 1,
},
departmentKey: {
type: String,
default: 'department_id',
},
employeeKey: {
type: String,
default: 'employee_id',
},
limit: {
type: Number,
default: 20,
},
flat: {
type: Boolean,
default: false,
},
otherOptions: {
type: Array,
default: () => [],
}
},
data() {
return {}
},
inject: {
provide_allTreeDepAndEmp: {
from: 'provide_allTreeDepAndEmp',
},
readOnly: {
from: 'readOnly',
default: false,
},
},
computed: {
treeValue: {
get() {
return this.value
},
set(val) {
this.$emit('change', val)
return val
},
},
allTreeDepAndEmp() {
return this.provide_allTreeDepAndEmp()
},
employeeTreeSelectOption() {
return formatOption(
[
..._.cloneDeep((Array.isArray(this.allTreeDepAndEmp) ? this.allTreeDepAndEmp : [])),
..._.cloneDeep((Array.isArray(this.otherOptions) ? this.otherOptions : []))
],
this.idType,
false,
this.departmentKey,
this.employeeKey
)
},
},
methods: {},
}
</script>
<style scoped></style>

View File

@@ -41,7 +41,7 @@ services:
- redis
cmdb-api:
image: registry.cn-hangzhou.aliyuncs.com/veops/cmdb-api:2.4.12
image: registry.cn-hangzhou.aliyuncs.com/veops/cmdb-api:2.4.13
container_name: cmdb-api
env_file:
- .env
@@ -71,7 +71,7 @@ services:
flask cmdb-init-acl
flask init-import-user-from-acl
flask init-department
flask cmdb-patch -v 2.4.12
flask cmdb-patch -v 2.4.13
flask cmdb-counter > counter.log 2>&1
networks:
new:
@@ -84,7 +84,7 @@ services:
test: "ps aux|grep -v grep|grep -v '1 root'|grep gunicorn || exit 1"
cmdb-ui:
image: registry.cn-hangzhou.aliyuncs.com/veops/cmdb-ui:2.4.12
image: registry.cn-hangzhou.aliyuncs.com/veops/cmdb-ui:2.4.13
container_name: cmdb-ui
depends_on:
cmdb-api: