Merge pull request 'main' (#142) from main into prod

Reviewed-on: #142
This commit is contained in:
charles 2026-07-17 15:31:50 +08:00
commit c2d2eeee51
44 changed files with 1439 additions and 324 deletions

View File

@ -1,3 +1,19 @@
async def get_password_tenant_orgid(sor, ns):
domain_name = ns.get('domain_name')
if (not domain_name) and ns.get('url_link'):
domain_name = ns.get('url_link').split("//")[1].split("/")[0]
if domain_name:
domain_name = domain_name.strip().replace('https://', '').replace('http://', '').replace('/', '')
if 'localhost' in domain_name:
domain_name = 'dev.opencomputing.cn'
reseller = await sor.R('reseller', {'domain_name': domain_name, 'del_flg': '0'})
if len(reseller) >= 1:
return reseller[0]['orgid']
org = await sor.R('organization', {'org_type': '0', 'del_flg': '0'})
if len(org) >= 1:
return org[0]['id']
return None
async def forgotPassword(ns):
"""
忘记密码:校验短信验证码后重置密码。
@ -25,6 +41,10 @@ async def forgotPassword(ns):
db = DBPools()
async with db.sqlorContext('kboss') as sor:
try:
tenant_orgid = await get_password_tenant_orgid(sor, ns)
if not tenant_orgid:
return {'status': False, 'msg': '未找到当前域名所属租户'}
code = await sor.R('validatecode', {'id': ns.get('codeid'), 'vcode': ns.get('vcode')})
if code:
create_at = code[0]['create_at']
@ -37,17 +57,21 @@ async def forgotPassword(ns):
user = None
if ns.get('id'):
users = await sor.R('users', {'id': ns.get('id'), 'del_flg': '0'})
user_sql = """select * from users where id = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns.get('id'), tenant_orgid)
users = await sor.sqlExe(user_sql, {})
if users:
user = users[0]
elif ns.get('mobile'):
users = await sor.R('users', {'mobile': ns.get('mobile'), 'del_flg': '0'})
user_sql = """select * from users where mobile = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns.get('mobile'), tenant_orgid)
users = await sor.sqlExe(user_sql, {})
if users:
user = users[0]
elif ns.get('username'):
users = await sor.R('users', {'username': ns.get('username'), 'del_flg': '0'})
user_sql = """select * from users where username = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns.get('username'), tenant_orgid)
users = await sor.sqlExe(user_sql, {})
if not users:
users = await sor.R('users', {'mobile': ns.get('username'), 'del_flg': '0'})
user_sql = """select * from users where mobile = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns.get('username'), tenant_orgid)
users = await sor.sqlExe(user_sql, {})
if users:
user = users[0]
else:

View File

@ -108,6 +108,22 @@ async def sync_cn_ai_user(userid=None, orgid=None, username=None, name=None, ema
'msg': f"sync_cn_ai_user{userid}同步用户失败: {e}"
}
async def get_register_tenant_orgid(sor, ns):
domain_name = ns.get('domain_name')
if (not domain_name) and ns.get('url_link'):
domain_name = ns.get('url_link').split("//")[1].split("/")[0]
if domain_name:
domain_name = domain_name.strip().replace('https://', '').replace('http://', '').replace('/', '')
if 'localhost' in domain_name:
domain_name = 'dev.opencomputing.cn'
reseller = await sor.R('reseller', {'domain_name': domain_name, 'del_flg': '0'})
if len(reseller) >= 1:
return reseller[0]['orgid']
org = await sor.R('organization', {'org_type': '0', 'del_flg': '0'})
if len(org) >= 1:
return org[0]['id']
return None
async def registerUser(ns):
"""
用户注册
@ -116,25 +132,30 @@ async def registerUser(ns):
db = DBPools()
async with db.sqlorContext('kboss') as sor:
if ns:
tenant_orgid = await get_register_tenant_orgid(sor, ns)
if not tenant_orgid:
return {'status': False, 'msg': '未找到当前域名所属租户'}
# 用户名没有 则用手机号作为用户名
if not ns.get('username'):
ns['username'] = ns['mobile']
if ns.get('username'):
userns = {'username': ns['username']}
isuser = await sor.R('users', userns)
user_sql = """select id from users where username = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns['username'], tenant_orgid)
isuser = await sor.sqlExe(user_sql, {})
if len(isuser) >= 1:
return {'status': False, 'msg': '用户名已注册'}
if ns.get('email'):
useremai = {'email': ns['email']}
isuser = await sor.R('users', useremai)
email_sql = """select id from users where email = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns['email'], tenant_orgid)
isuser = await sor.sqlExe(email_sql, {})
if len(isuser) >= 1:
return {'status': False, 'msg': '该邮箱已注册'}
usermobile = {'mobile':ns['mobile']}
isuser = await sor.R('users', usermobile)
mobile_sql = """select id from users where mobile = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns['mobile'], tenant_orgid)
isuser = await sor.sqlExe(mobile_sql, {})
if len(isuser) >= 1:
return {'status': False, 'msg': '该手机号已注册'}
# 用户名没有 则用手机号作为用户名
if not ns.get('username'):
ns['username'] = ns['mobile']
# if ns.get('password'):
# # 至少8位包含大小写字母、特殊字符、数字
@ -188,7 +209,9 @@ async def registerUser(ns):
userid = ns['id']
ns['orgid'] = orgid
ns['password'] = password_encode(ns['password'])
ns['tenant_orgid'] = tenant_orgid
await sor.C('users', ns)
ns.pop('tenant_orgid', None)
listrole = ['管理员', '客户']
for i in listrole:
role = await sor.R('role', {'role': i, 'org_type': ns['org_type']})
@ -274,7 +297,9 @@ async def registerUser(ns):
ns['orgid'] = org_id
# 新增用户信息
ns['tenant_orgid'] = tenant_orgid
await sor.C('users', ns)
ns.pop('tenant_orgid', None)
# 新增用户角色信息
listrole = ['管理员', '客户']

View File

@ -1,66 +1,29 @@
async def upload_file(ns={}):
import os
import base64
import datetime
file_name = ns.get('file_name')
file_content = ns.get('file_content') or ns.get('file_base64')
storage_type = ns.get('storage_type', 'local')
debug('ns: %s' % ns)
file_name = ns.get('file_data')
if not file_name:
return {
'status': False,
'msg': '请传递file_name'
'msg': 'file_data不能为空'
}
if not file_content:
domain_name = ns.get('domain_name')
if not domain_name:
return {
'status': False,
'msg': '请传递file_content'
}
if storage_type != 'local':
return {
'status': False,
'msg': '暂不支持该存储类型'
}
safe_file_name = os.path.basename(file_name).replace('\\', '').replace('/', '')
if not safe_file_name:
return {
'status': False,
'msg': 'file_name错误'
'msg': 'domain_name不能为空'
}
if 'localhost' in domain_name:
domain_name = 'dev.opencomputing.cn'
save_url = 'https://%s/idfile?path=%s' % (domain_name, file_name)
try:
if ',' in file_content and file_content.split(',', 1)[0].startswith('data:'):
file_content = file_content.split(',', 1)[1]
file_bytes = base64.b64decode(file_content)
except Exception as e:
return {
'status': False,
'msg': '文件内容解析失败, %s' % str(e)
}
now_date = datetime.datetime.now()
date_path = now_date.strftime('%Y/%m/%d')
relative_path = '%s/%s' % (date_path, safe_file_name)
base_path = '/data'
save_dir = os.path.join(base_path, now_date.strftime('%Y'), now_date.strftime('%m'), now_date.strftime('%d'))
save_path = os.path.join(save_dir, safe_file_name)
try:
if not os.path.exists(save_dir):
os.makedirs(save_dir)
with open(save_path, 'wb') as f:
f.write(file_bytes)
return {
'status': True,
'msg': 'upload success',
'data': {
'storage_type': storage_type,
'file_name': safe_file_name,
'file_path': relative_path,
'save_path': save_path,
'file_size': len(file_bytes)
'save_url': save_url
}
}
except Exception as e:

View File

@ -0,0 +1,327 @@
# 用户按租户隔离改造规划
## 背景
当前用户注册时,手机号和用户名按 `users` 全表做唯一性判断。两个不同域名下,如果使用同一个手机号注册,会触发“该手机号已注册”。登录时也存在类似问题:手机号、用户名查询没有先限定当前域名所属租户,若数据库中出现跨域名同手机号用户,可能查到错误用户。
本次改造目标是支持“不同域名/站点下允许同手机号注册和登录”,同时不影响余额、计费、扣费、结算链路。
## 核心原则
1. 前端不传 `tenant_orgid`
2. 后端根据当前访问域名解析 `tenant_orgid`
3. 注册、登录、找回密码、验证码等入口按 `tenant_orgid + 手机号/用户名` 查询用户。
4. 不改 `users.id``users.orgid``organization.id``customer.customerid`
5. 不改账务核心表:`account``ledger``customer``bz_order``order_goods` 等。
6. 当前数据库暂时无法新增 INDEX/UNIQUE唯一性由代码逻辑保证。
## tenant_orgid 来源
`tenant_orgid` 表示当前站点所属租户机构 ID由后端通过域名解析。
解析规则:
1. 从 `domain_name` 获取域名;如果只有 `url_link`,从 `url_link` 中解析域名。
2. 查询 `reseller`
```sql
select orgid
from reseller
where domain_name = '当前域名'
and del_flg = '0'
limit 1;
```
1. 如果查到,说明当前是分销商站点:
```text
tenant_orgid:reseller.orgid
```
1. 如果查不到,说明当前是主站,查询主站机构:
```sql
select id
from organization
where org_type = '0'
and del_flg = '0'
limit 1;
```
主站场景:
```text
tenant_orgid:主站机构id
```
## 数据库改造
只改 `users` 表,新增字段,不新增索引。
```sql
ALTER TABLE users
ADD COLUMN tenant_orgid varchar(32) DEFAULT NULL COMMENT '注册/登录所属租户机构id';
```
### 历史数据回填
客户用户通常满足:`users.orgid` 是客户机构,`organization.parentid` 是该客户所属分销商或主站机构。
```sql
UPDATE users u
LEFT JOIN organization o ON u.orgid = o.id
SET u.tenant_orgid = o.parentid
WHERE u.tenant_orgid IS NULL
AND o.parentid IS NOT NULL;
# 注意管理员角色用户
```
分销商自身用户满足:`users.orgid = reseller.orgid`
```sql
UPDATE users u
INNER JOIN reseller r ON u.orgid = r.orgid
SET u.tenant_orgid = r.orgid
WHERE u.tenant_orgid IS NULL;
```
主站/内部用户兜底回填为主站机构。
```sql
UPDATE users u
SET u.tenant_orgid = (
SELECT id FROM organization WHERE org_type = '0' AND del_flg = '0' LIMIT 1
)
WHERE u.tenant_orgid IS NULL;
```
回填后检查:
```sql
select id, username, mobile, orgid, tenant_orgid
from users
where tenant_orgid is null;
```
## 注册接口改造
文件:
```text
b/customer/registerUser.dspy
```
改造点:
1. 注册开始先解析 `tenant_orgid`
2. 手机号重复校验从全局校验:
```text
mobile:手机号
```
改成租户内校验:
```sql
select id
from users
where mobile = '手机号'
and tenant_orgid = '当前tenant_orgid'
and del_flg = '0'
limit 1;
```
1. 用户名重复校验也加 `tenant_orgid`
2. 邮箱重复校验建议也加 `tenant_orgid`
3. 新增 `users` 时写入:
```text
tenant_orgid:当前tenant_orgid
```
1. 如果未传 `username`,仍可用手机号作为用户名,但唯一范围变为当前租户内。
## 登录接口改造
文件:
```text
b/user/loginUser.dspy
b/user/logintype.dspy
```
改造点:
1. 登录开始先解析 `tenant_orgid`
2. 手机号验证码登录按当前租户查询:
```sql
select *
from users
where mobile = '手机号'
and tenant_orgid = '当前tenant_orgid'
and del_flg = '0'
limit 1;
```
1. 用户名密码登录按当前租户查询:
```sql
select *
from users
where username = '用户名'
and password = '加密后密码'
and tenant_orgid = '当前tenant_orgid'
and del_flg = '0'
limit 1;
```
1. 如果使用手机号作为 `username`,也必须加 `tenant_orgid`
2. 原有域名归属判断可以保留,但查用户时必须先限定租户,避免查到其他域名同手机号用户。
## 登录失败锁定改造
文件:
```text
b/user/logintype.dspy
```
当前 `login_fail_log``user_name` 记录失败次数。跨域名同手机号后A 域名失败可能锁定 B 域名同手机号。
暂不改表时,建议使用拼接 key
```text
login_fail_key = tenant_orgid + ':' + username_or_mobile
```
然后传给:
```text
handle_login_failed(login_fail_key)
check_login_allowed(login_fail_key)
```
## 找回密码和验证码改造
建议同步改造:
```text
b/customer/forgotPassword.dspy
b/user/retrievecode.dspy
b/user/mobilecode.dspy
```
原因:这些接口也会通过手机号或用户名全局查 `users`。改造后应先解析 `tenant_orgid`,再按当前租户查用户。
查询条件示例:
```sql
select *
from users
where mobile = '手机号'
and tenant_orgid = '当前tenant_orgid'
and del_flg = '0'
limit 1;
```
## 其他代码排查
全局排查这些模式:
```text
sor.R('users', {'mobile': ...})
sor.R('users', {'username': ...})
```
处理原则:
1. 注册、登录、找回密码、验证码:必须加 `tenant_orgid`
2. 前台用户入口:优先加 `tenant_orgid`
3. 平台后台管理查询:如果需要跨租户管理,可以不加;如果是当前站点后台,则应加。
4. 账务、订单、结算:通常按 `userid``orgid``customerid` 查询,不需要因手机号隔离改造。
## 对余额、计费、扣费、结算的影响
按本规划实施,理论上不影响账务链路。
原因是账务核心链路依赖以下字段:
```text
users.id
users.orgid
organization.id
customer.customerid
account.orgid
bz_order.customerid
bz_order.userid
```
本次只新增 `users.tenant_orgid`,并改变登录/注册时如何定位用户,不改变已有账务关联 ID。
## 并发风险
由于当前不能新增数据库唯一索引,租户内手机号唯一性只能由代码保证。
风险:
```text
两个请求同时注册同一 tenant_orgid + mobile可能同时通过注册前校验。
```
降低风险方案:
1. 注册前查询一次。
2. 插入前尽量保持注册逻辑短。
3. 插入后再查询一次同 `tenant_orgid + mobile + del_flg = 0` 的用户数量。
4. 如果发现重复,返回异常并人工处理。
后续如果数据库允许新增唯一约束,再补数据库唯一约束。
## 建议实施顺序
1. 给 `users` 增加 `tenant_orgid` 字段。
2. 回填历史用户 `tenant_orgid`
3. 抽出或复制一段 `tenant_orgid` 解析逻辑到注册/登录相关接口。
4. 改造 `b/customer/registerUser.dspy`
5. 改造 `b/user/logintype.dspy`
6. 改造 `b/user/loginUser.dspy`
7. 改造 `b/customer/forgotPassword.dspy`
8. 改造 `b/user/retrievecode.dspy`
9. 改造 `b/user/mobilecode.dspy`
10. 全局排查剩余按 `mobile``username``users` 的代码。
11. 测试主站注册登录。
12. 测试分销商 A 注册登录。
13. 测试分销商 B 使用同手机号注册登录。
14. 测试找回密码、验证码登录、用户名密码登录。
15. 测试余额、下单、扣费、结算查询链路是否仍按原 `orgid/customerid` 工作。
## 验收标准
1. 同一域名下,同手机号不能重复注册。
2. 不同域名下,同手机号可以分别注册。
3. 不同域名下,同手机号登录时只登录当前域名对应用户。
4. 登录失败锁定只影响当前域名对应用户。
5. 找回密码只影响当前域名对应用户。
6. 原有用户能正常登录。
7. 订单、余额、扣费、结算不串账。

View File

@ -46,7 +46,7 @@ async def get_ipc_logo(ns={}):
if '?domain_name=' in url_link:
domain_url = url_link.split('?domain_name=')[1]
# 如果是业主机构
if ('ncmatch' in domain_url or '9527' in domain_url or '8889' in domain_url or '8891' in domain_url or domain_url in ['xterm.kaiyuancloud.cn','www.kaiyuancloud.cn', 'dev.kaiyuancloud.cn', 'dev.opencomputing.cn', 'test.kaiyuancloud.cn', 'localhost', 'www.opencomputing.cn', 'opencomputing.cn']) and '/domain/' not in url_link:
if ('ncmatch' in domain_url or 'zgcopc.opencomputing.cn' in domain_url or '9527' in domain_url or '8889' in domain_url or '8891' in domain_url or domain_url in ['xterm.kaiyuancloud.cn','www.kaiyuancloud.cn', 'dev.kaiyuancloud.cn', 'dev.opencomputing.cn', 'test.kaiyuancloud.cn', 'localhost', 'www.opencomputing.cn', 'opencomputing.cn']) and '/domain/' not in url_link:
yezhu_info = (await sor.R('organization', {'org_type': '0'}))[0]
domain_res = (await sor.R('params', {'pname': '业主机构域名'}))[0]['pvalue']
# yezhu = {
@ -91,6 +91,37 @@ async def get_ipc_logo(ns={}):
]
}
}
elif 'zgcopc.opencomputing.cn' in domain_url or '9527' in domain_url:
yezhu['logo'] = "https://zgcopc.opencomputing.cn/idfile?path=logo_zgcopc.png"
yezhu['additional_msg'] = {
"home": {
"logoImg": "https://zgcopc.opencomputing.cn/idfile?path=logo_zgcopc.png",
"bannerTitle": "中关村国际孵化器",
"adress": "北京市海淀区上地信息路2号",
"footerTitle": "中关村国际孵化器有限公司",
'qrCode': 'https://zgcopc.opencomputing.cn/idfile?path=firstpagehot/ncmatch_inquiry.jpg',
'footer_info': '京ICP备13007859号-1 北京中关村国际孵化器有限公司',
"copyright": "",
"domain_name": "zgcopc.opencomputing.cn",
"email": "zhangjian@bjzai.org.cn",
"license": "京ICP备13007859号-1",
"logo": "https://zgcopc.opencomputing.cn/idfile?path=logo_zgcopc.png",
"mobile": "13601380912",
"publicsecurity": "11010502054007",
"businesslicense": "京ICP备13007859号-1"
},
"about": {
"title": "开元数智",
"logo": "https://www.opencomputing.cn/idfile?path=logo_ncmatch.png",
"content": [
"开元数智北京科技有限公司以下简称“开元数智”创立于2022年总部位于北京中关村科技园区现已形成以上海、南京、深圳、济南为区域枢纽的全国性服务网络。作为新型数字基础设施建设领域的创新企业公司主营业务涵盖智能算力中心规划建设与运营、算力网络体系构建、企业级AI算力服务及大模型应用整体解决方案。",
"公司自主研发的NCMatch智能算力公共服务平台作为行业领先的一站式人工智能算力服务解决方案已构建云网算数智“五位一体”的服务体系。该平台深度整合算力资源调度系统、智能算法模型库及行业应用矩阵为高等院校、科研院所、政企机构及人工智能开发企业提供专业级算力云服务全面覆盖基础算力供给、大模型训练调优及产业智能化升级等多维度需求。",
"开元数智作为国家算力网络建设的重要参与单位,已获得工信部、教育部等部委直属机构指导,并与中关村数智人工智能产业联盟、国家超级计算中心、南京未来网络研究院等权威机构建立战略合作关系。公司深度参与全国超算互联网及全国一体化算力网国家级重点工程,在算力基础设施建设领域具备成熟的技术积累和项目实施经验。",
"开元数智北京科技有限公司合作的客户有中国联通、中国电信、中国移动、未来网络、中信网络、国家超级计算济南中心、华为、阿里云、百度智能云、腾讯云、火山、智谱·AI、衔远科技、创新工场、竹间智能、深言科技、澜舟科技、商汤科技、MINMAX、云从科技、第四范式、深度求索、百川智能、Moonshot AI、昆仑万维、深蓝等。",
]
}
}
elif 'kaiyuancloud' in domain_url or 'opencomputing' in domain_url or 'localhost' in domain_url:
yezhu['additional_msg'] = {
"home": {

View File

@ -1,3 +1,15 @@
async def get_reseller_user_tenant_orgid(sor, orgid):
reseller = await sor.R('reseller', {'orgid': orgid, 'del_flg': '0'})
if len(reseller) >= 1:
return orgid
org = await sor.R('organization', {'id': orgid, 'del_flg': '0'})
if len(org) >= 1 and org[0].get('parentid'):
return org[0]['parentid']
main_org = await sor.R('organization', {'org_type': '0', 'del_flg': '0'})
if len(main_org) >= 1:
return main_org[0]['id']
return None
async def reseller_add_user(ns):
"""
添加用户
@ -15,11 +27,16 @@ async def reseller_add_user(ns):
try:
ns['id'] = uuid()
ns['password'] = password_encode(ns['password'])
user = await sor.R('users', {'username': ns['username']})
tenant_orgid = await get_reseller_user_tenant_orgid(sor, ns.get('orgid'))
if not tenant_orgid:
return {'status': False, 'msg': '未找到用户所属租户'}
user_sql = """select id from users where username = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns['username'], tenant_orgid)
user = await sor.sqlExe(user_sql, {})
if len(user) >= 1:
return {'status': False, 'msg': '用户名重复'}
rolereacs = await sor.R('role', {'role': '管理员', 'del_flg': '0', 'org_type': '1'})
ns['user_reseller'] = '1'
ns['tenant_orgid'] = tenant_orgid
await sor.C('users', ns)
await sor.C('userrole', {'userid': ns['id'], 'roleid': rolereacs[0]['id'], 'id': uuid()})
return {'status': True, 'msg': '用户添加成功', 'userid': ns['id']}

View File

@ -1,3 +1,15 @@
async def get_user_tenant_orgid(sor, orgid):
reseller = await sor.R('reseller', {'orgid': orgid, 'del_flg': '0'})
if len(reseller) >= 1:
return orgid
org = await sor.R('organization', {'id': orgid, 'del_flg': '0'})
if len(org) >= 1 and org[0].get('parentid'):
return org[0]['parentid']
main_org = await sor.R('organization', {'org_type': '0', 'del_flg': '0'})
if len(main_org) >= 1:
return main_org[0]['id']
return None
async def addUser(ns):
"""
添加用户
@ -17,11 +29,16 @@ async def addUser(ns):
else:
user_reseller = '0'
ns['password'] = password_encode(ns['password'])
user = await sor.R('users', {'username': ns['username']})
tenant_orgid = await get_user_tenant_orgid(sor, ns.get('orgid'))
if not tenant_orgid:
return {'status': False, 'msg': '未找到用户所属租户'}
user_sql = """select id from users where username = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns['username'], tenant_orgid)
user = await sor.sqlExe(user_sql, {})
if len(user) >= 1:
return {'status': False, 'msg': '用户名重复'}
ns['id'] = uuid()
if not ns.get('type'):
ns['tenant_orgid'] = tenant_orgid
await sor.C('users', ns)
return {'status': True, 'msg': '添加成功', 'userid': ns['id']}
rolereacs = await sor.R('role', {'role': '管理员', 'del_flg': '0', 'org_type': '0'})
@ -30,6 +47,7 @@ async def addUser(ns):
return {'status': False, 'msg': '已有管理员'}
ns['orgid'] = 'mIWUHBeeDM8mwAFPIQ8pS'
ns['user_reseller'] = user_reseller
ns['tenant_orgid'] = ns['orgid']
await sor.C('users', ns)
ns['orgid'] = 'mIWUHBeeDM8mwAFPIQ8pS'
await sor.C('userrole', {'userid': ns['id'], 'roleid': rolereacs[0]['id'], 'id': uuid()})

View File

@ -57,6 +57,21 @@ async def check_wechat_login_status(code):
except Exception as e:
raise e
async def get_login_tenant_orgid(sor, ns):
domain_name = ns.get('domain_name')
if (not domain_name) and ns.get('url_link'):
domain_name = ns.get('url_link').split("//")[1].split("/")[0]
if domain_name:
domain_name = domain_name.strip().replace('https://', '').replace('http://', '').replace('/', '')
if 'localhost' in domain_name:
domain_name = 'dev.opencomputing.cn'
reseller = await sor.R('reseller', {'domain_name': domain_name, 'del_flg': '0'})
if len(reseller) >= 1:
return reseller[0]['orgid']
org = await sor.R('organization', {'org_type': '0', 'del_flg': '0'})
if len(org) >= 1:
return org[0]['id']
return None
async def loginUser(ns):
"""
@ -65,6 +80,9 @@ async def loginUser(ns):
db = DBPools()
async with db.sqlorContext('kboss') as sor:
if ns:
tenant_orgid = await get_login_tenant_orgid(sor, ns)
if not tenant_orgid and ns.get('username') not in ['admin', '开元云(北京)科技有限公司']:
return {'status': False, 'msg': '未找到当前域名所属租户'}
# 处理微信授权后的登录
if ns.get('wechatCode'): # 微信扫码登录后的`code`
wechat_user_info = await check_wechat_login_status(ns.get('wechatCode'))
@ -91,7 +109,8 @@ async def loginUser(ns):
if wechat_user_info:
openid_f = wechat_user_info['openid']
# 查询数据库查看openid是否已绑定账户
userreacs = await sor.R('users', {'wechat_openid': openid_f, 'del_flg': '0'})
wechat_sql = """select * from users where wechat_openid = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (openid_f, tenant_orgid)
userreacs = await sor.sqlExe(wechat_sql, {})
if len(userreacs) >= 1:
# 如果openid绑定了用户, 查找用户名和密码 使用账号密码登录
ns['username'] = userreacs[0]['username']
@ -152,10 +171,12 @@ async def loginUser(ns):
type = 0
if type1 == 1:
# 手机号验证码登录
userreacs = await sor.R('users', {'mobile': ns.get('mobile')})
user_sql = """select * from users where mobile = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns.get('mobile'), tenant_orgid)
userreacs = await sor.sqlExe(user_sql, {})
if not userreacs:
userreacs = await sor.R('users', {'username': ns.get('username')})
user_sql = """select * from users where username = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns.get('username'), tenant_orgid)
userreacs = await sor.sqlExe(user_sql, {})
# 如果是微信扫码后绑定已有账号
if ns.get('wechat_openid'):
@ -170,14 +191,17 @@ async def loginUser(ns):
else:
ns['password'] = password_encode(ns['password'])
ns['del_flg'] = '0'
userreacs = await sor.R('users', {'username': ns.get('username'), 'password': ns.get('password')})
if ns.get('username') in ['admin', '开元云(北京)科技有限公司']:
userreacs = await sor.R('users', {'username': ns.get('username'), 'password': ns.get('password'), 'del_flg': '0'})
else:
user_sql = """select * from users where username = '%s' and password = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns.get('username'), ns.get('password'), tenant_orgid)
userreacs = await sor.sqlExe(user_sql, {})
if len(userreacs) >= 1:
type += 1
await remember_user(userreacs[0]['id'], username=userreacs[0]['username'], userorgid=userreacs[0]['orgid'])
else:
ns['mobile'] = ns['username']
ns.pop('username')
userreacs = await sor.R('users', ns)
user_sql = """select * from users where mobile = '%s' and password = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns.get('username'), ns.get('password'), tenant_orgid)
userreacs = await sor.sqlExe(user_sql, {})
if len(userreacs) >= 1:
type += 1
await remember_user(userreacs[0]['id'], username=userreacs[0]['username'], userorgid=userreacs[0]['orgid'])

View File

@ -93,6 +93,22 @@ async def check_login_allowed(user_name):
print("exception:", user_name, e)
return {'status': False, 'msg': '登录操作失败, %s' % str(e)}
async def get_login_tenant_orgid(sor, ns):
domain_name = ns.get('domain_name')
if (not domain_name) and ns.get('url_link'):
domain_name = ns.get('url_link').split("//")[1].split("/")[0]
if domain_name:
domain_name = domain_name.strip().replace('https://', '').replace('http://', '').replace('/', '')
if 'localhost' in domain_name:
domain_name = 'dev.opencomputing.cn'
reseller = await sor.R('reseller', {'domain_name': domain_name, 'del_flg': '0'})
if len(reseller) >= 1:
return reseller[0]['orgid']
org = await sor.R('organization', {'org_type': '0', 'del_flg': '0'})
if len(org) >= 1:
return org[0]['id']
return None
async def logintype(ns):
"""
1、判断用户是否为主级如果在reseller没要找到数据证明就是主级
@ -140,11 +156,16 @@ async def logintype(ns):
async with db.sqlorContext('kboss') as sor:
domain_name = ns.get('domain_name')
tenant_orgid = await get_login_tenant_orgid(sor, ns)
if not tenant_orgid and ns.get('username') != 'admin':
return {'status': False, 'msg': '未找到当前域名所属租户'}
login_name = ns.get('username') or ns.get('mobile') or ''
login_fail_key = '%s:%s' % (tenant_orgid, login_name)
# if domain_name in ['www.opencomputing.cn', 'dev.opencomputing.cn', 'localhost:9527', 'www.ncmatch.cn'] and ns.get('username') not in ['开元云(北京)科技有限公司', 'admin', 'kyy_root', 'kyy_kaiyuan', 'kyacloud', 'kyy_运营', 'kyy_销售', 'kyy_财务', '测试用户', 'kycloud']:
if domain_name in ['www.opencomputing.cn', 'dev.opencomputing.cn', 'localhost:9527', 'www.ncmatch.cn'] and not ns.get('username') and not ns.get('codeid'):
# 登录失败次数限制
login_allowed = await check_login_allowed(ns.get('username'))
login_allowed = await check_login_allowed(login_fail_key)
if not login_allowed.get('status'):
return {'status': False, 'msg': login_allowed.get('msg')}
@ -153,7 +174,8 @@ async def logintype(ns):
'status': False,
'msg': '请输入手机号'
}
real_mobile_li = await sor.R('users', {'username': ns['username']})
real_mobile_sql = """select * from users where username = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns.get('username'), tenant_orgid)
real_mobile_li = await sor.sqlExe(real_mobile_sql, {})
if not real_mobile_li:
return {
'status': False,
@ -179,13 +201,14 @@ async def logintype(ns):
}
code = await sor.R('validatecode', {'id': ns.get('codeid'), 'vcode': ns.get('vcode')})
if len(code) < 1:
await handle_login_failed(ns.get('username'))
await handle_login_failed(login_fail_key)
return {'status': False, 'msg': '验证码不正确'}
password = password_encode(ns['password'])
users = await sor.R('users', {'username': ns.get('username'), 'password': password})
users_sql = """select * from users where username = '%s' and password = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns.get('username'), password, tenant_orgid)
users = await sor.sqlExe(users_sql, {})
if len(users) < 1:
await handle_login_failed(ns.get('username'))
await handle_login_failed(login_fail_key)
return {"status": False,'msg':'用户名或密码错误'}
return {'status': True}
@ -199,14 +222,20 @@ async def logintype(ns):
return {'status': False, 'msg': '验证码不正确'}
if type == 1:
# 手机号登录
users = await sor.R('users', {'mobile': ns.get('mobile')})
users_sql = """select * from users where mobile = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns.get('mobile'), tenant_orgid)
users = await sor.sqlExe(users_sql, {})
if not users:
users = await sor.R('users', {'username': ns.get('username')})
users_sql = """select * from users where username = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns.get('username'), tenant_orgid)
users = await sor.sqlExe(users_sql, {})
else:
password = password_encode(ns['password'])
users = await sor.R('users', {'username': ns.get('username'), 'password': password})
if ns.get('username') == 'admin':
users = await sor.R('users', {'username': ns.get('username'), 'password': password})
else:
users_sql = """select * from users where username = '%s' and password = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (ns.get('username'), password, tenant_orgid)
users = await sor.sqlExe(users_sql, {})
if len(users) < 1:
await handle_login_failed(ns.get('username'))
await handle_login_failed(login_fail_key)
return {"status": False,'msg':'用户名或密码错误'}
elif ns.get('username') == "admin":
return {'status': True}

View File

@ -65,10 +65,30 @@ async def handle_login_failed(mobile: str) -> bool:
return True
async def get_mobilecode_tenant_orgid(sor, ns):
domain_name = ns.get('domain_name')
if (not domain_name) and ns.get('url_link'):
domain_name = ns.get('url_link').split("//")[1].split("/")[0]
if domain_name:
domain_name = domain_name.strip().replace('https://', '').replace('http://', '').replace('/', '')
if 'localhost' in domain_name:
domain_name = 'dev.opencomputing.cn'
reseller = await sor.R('reseller', {'domain_name': domain_name, 'del_flg': '0'})
if len(reseller) >= 1:
return reseller[0]['orgid']
org = await sor.R('organization', {'org_type': '0', 'del_flg': '0'})
if len(org) >= 1:
return org[0]['id']
return None
async def mobilecode(ns):
"""发送短信验证码,支持注册和登录筛选"""
db = DBPools()
async with db.sqlorContext('kboss') as sor:
tenant_orgid = await get_mobilecode_tenant_orgid(sor, ns)
if not tenant_orgid:
return {'status': False, 'msg': '未找到当前域名所属租户'}
# 获取操作类型register 或 login
action_type = ns.get('action_type') # register 或 login
@ -81,11 +101,13 @@ async def mobilecode(ns):
return {'status': False, 'msg': '手机号不能为空'}
# 检查短信发送限制
can_send = await handle_login_failed(mobile)
sms_limit_key = '%s:%s' % (tenant_orgid[:8], mobile)
can_send = await handle_login_failed(sms_limit_key)
if not can_send:
return {'status': False, 'msg': '发送过于频繁请10分钟后再试'}
userreacs = await sor.R('users', {'mobile': mobile, 'del_flg': '0'})
user_sql = """select * from users where mobile = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (mobile, tenant_orgid)
userreacs = await sor.sqlExe(user_sql, {})
# 注册逻辑:检查手机号是否已存在
if action_type == 'register':
@ -128,12 +150,14 @@ async def mobilecode(ns):
# 原有逻辑如果没有指定action_type保持原有逻辑
else:
type = 0
ns['del_flg'] = '0'
userreacs = await sor.R('users', ns)
username = ns.get('username')
user_sql = """select * from users where username = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (username, tenant_orgid)
userreacs = await sor.sqlExe(user_sql, {})
if len(userreacs) >= 1:
type += 1
else:
userreacs = await sor.R('users', {'mobile': ns['username'], 'del_flg': '0'})
user_sql = """select * from users where mobile = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (username, tenant_orgid)
userreacs = await sor.sqlExe(user_sql, {})
if len(userreacs) >= 1:
type += 1
if type >= 1:

View File

@ -1,14 +1,37 @@
async def get_retrieve_tenant_orgid(sor, ns):
domain_name = ns.get('domain_name')
if (not domain_name) and ns.get('url_link'):
domain_name = ns.get('url_link').split("//")[1].split("/")[0]
if domain_name:
domain_name = domain_name.strip().replace('https://', '').replace('http://', '').replace('/', '')
if 'localhost' in domain_name:
domain_name = 'dev.opencomputing.cn'
reseller = await sor.R('reseller', {'domain_name': domain_name, 'del_flg': '0'})
if len(reseller) >= 1:
return reseller[0]['orgid']
org = await sor.R('organization', {'org_type': '0', 'del_flg': '0'})
if len(org) >= 1:
return org[0]['id']
return None
async def retrievecode(ns):
"""找回密码发送短信验证码"""
db = DBPools()
async with db.sqlorContext('kboss') as sor:
tenant_orgid = await get_retrieve_tenant_orgid(sor, ns)
if not tenant_orgid:
return {'status': False, 'msg': '未找到当前域名所属租户'}
type = 0
ns['del_flg'] = '0'
userreacs = await sor.R('users', ns)
username = ns.get('username') or ns.get('mobile')
if not username:
return {'status': False, 'msg': '用户标识不能为空'}
user_sql = """select * from users where username = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (username, tenant_orgid)
userreacs = await sor.sqlExe(user_sql, {})
if len(userreacs) >= 1:
type += 1
else:
userreacs = await sor.R('users', {'mobile': ns['username'], 'del_flg': '0'})
user_sql = """select * from users where mobile = '%s' and tenant_orgid = '%s' and del_flg = '0' limit 1;""" % (username, tenant_orgid)
userreacs = await sor.sqlExe(user_sql, {})
if len(userreacs) >= 1:
type += 1
if type >= 1:

View File

@ -313,10 +313,193 @@ body {
width: 100% !important;
}
.news-confirm-box {
position: relative;
width: 460px !important;
padding: 28px 32px 24px;
border: 0;
border-radius: 20px;
box-shadow: 0 20px 56px rgba(15, 23, 42, 0.2);
}
.news-confirm-box .el-message-box__header {
padding: 0;
text-align: left;
}
.news-confirm-box .el-message-box__title {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding-left: 0;
text-align: left;
color: #1f2937;
font-size: 20px;
font-weight: 800;
line-height: 1.35;
}
.news-confirm-box .el-message-box__title span {
display: block;
flex: 1;
text-align: left;
}
.news-confirm-box .el-message-box__status {
display: none !important;
}
.news-confirm-box .el-message-box__title::before {
width: 44px;
height: 44px;
flex: 0 0 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 24px;
font-weight: 800;
line-height: 1;
}
.news-confirm-box--warning .el-message-box__title::before {
content: '!';
color: #f59e0b;
background: #fff7ed;
}
.news-confirm-box--danger .el-message-box__title::before {
content: '×';
color: #ef4444;
background: #fef2f2;
}
.news-confirm-box--success .el-message-box__title::before {
content: '✓';
color: #22c55e;
background: #f0fdf4;
}
.news-confirm-box .el-message-box__headerbtn {
display: none;
}
.news-confirm-box .el-message-box__content {
position: static;
padding: 16px 0 0 58px;
text-align: left;
}
.news-confirm-box .el-message-box__message {
margin-left: 0 !important;
padding-left: 0 !important;
display: block;
width: 100%;
text-align: left;
color: #64748b;
font-size: 16px;
line-height: 1.65;
}
.news-confirm-box .el-message-box__message p {
margin: 0;
text-align: left;
}
.news-confirm-box .el-message-box__btns {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 24px 0 0;
}
.news-confirm-btn {
min-width: 88px;
height: 42px;
margin-left: 0 !important;
padding: 0 22px;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
}
.news-confirm-btn--cancel {
color: #64748b;
background: #fff;
border-color: #d8e0ec;
}
.news-confirm-btn--cancel:hover,
.news-confirm-btn--cancel:focus {
color: #334155;
background: #f8fafc;
border-color: #cbd5e1;
}
.news-confirm-btn--confirm {
color: #fff;
background: #3b82f6;
border-color: #3b82f6;
}
.news-confirm-btn--confirm:hover,
.news-confirm-btn--confirm:focus {
color: #fff;
background: #2563eb;
border-color: #2563eb;
}
.news-confirm-btn--confirm.is-danger {
background: #ef4444;
border-color: #ef4444;
}
.news-confirm-btn--confirm.is-danger:hover,
.news-confirm-btn--confirm.is-danger:focus {
background: #dc2626;
border-color: #dc2626;
}
.news-confirm-btn--confirm.is-success {
background: #22c55e;
border-color: #22c55e;
}
.news-confirm-btn--confirm.is-success:hover,
.news-confirm-btn--confirm.is-success:focus {
background: #16a34a;
border-color: #16a34a;
}
@media screen and (max-width: 760px) {
.el-message-box {
width: 320px !important;
}
.news-confirm-box {
width: calc(100vw - 32px) !important;
padding: 24px 20px 22px;
}
.news-confirm-box .el-message-box__title {
font-size: 18px;
}
.news-confirm-box .el-message-box__title::before {
width: 40px;
height: 40px;
flex-basis: 40px;
font-size: 22px;
}
.news-confirm-box .el-message-box__content {
padding-left: 52px;
}
.news-confirm-box .el-message-box__message {
font-size: 15px;
}
}
* {

View File

@ -66,4 +66,14 @@ export const reqUnpublishNews = (data) => {
method: 'get',
params: data
})
}
// 新闻图片上传
export const reqUploadNewsImage = (data) => {
return request({
url: '/docs/upload_file.dspy',
method: 'post',
data,
headers: { 'Content-Type': 'multipart/form-data' }
})
}

View File

@ -37,35 +37,99 @@ export default {
solutionCards: {
collaboration: {
title: 'Collaborative Intelligence',
subtitle: 'Multi-Agent Coordination'
subtitle: 'Multi-Agent Collaboration',
detailTitle: 'Collective Intelligence',
detailSubTitle: 'Multi-Agent Collaboration',
detailDesc: 'Multiple AI agents collaborate autonomously based on role specialization, completing the full workflow of information gathering, data analysis, logical reasoning, and conclusion generation. With dynamic task allocation and shared context, complex business decisions shift from human sequential workflows to parallel agent execution, significantly reducing cycle times for high-knowledge-density work.',
metrics: {
first: { value: '10x+', label: 'Knowledge work efficiency improvement' },
second: { value: 'Real-time', label: 'Multi-source information fusion' },
third: { value: 'High complexity', label: 'Decision scenario adaptability' }
}
},
spatial: {
title: 'Spatial Intelligence',
subtitle: 'GIS Smart Analysis'
subtitle: 'GIS Intelligent Analysis',
detailTitle: 'Spatial Awakening',
detailSubTitle: 'GIS Intelligent Analysis',
detailDesc: 'Integrating Geographic Information Systems, satellite remote sensing imagery, and drone aerial photography, AI automatically identifies land feature boundaries, analyzes spatial relationships through overlay analysis, and verifies compliance constraints. Geographic spatial judgments that once required manual layer-by-layer comparison are now upgraded to automated spatial intelligence decisions delivered in minutes.',
metrics: {
first: { value: 'Days to minutes', label: 'Spatial analysis' },
second: { value: '90%+', label: 'Violation detection accuracy' },
third: { value: '70%', label: 'Labor cost savings' }
}
},
predictive: {
title: 'Predictive Insight',
subtitle: 'Intelligent Forecasting Engine'
subtitle: 'Intelligent Prediction Engine',
detailTitle: 'One Step Ahead',
detailSubTitle: 'Intelligent Prediction Engine',
detailDesc: 'Integrating physics-based models with data-driven approaches, the system collects real-time equipment operating parameters to autonomously identify early anomaly characteristics and accurately predict remaining useful life. Warnings are issued 72 hours before actual failure occurs, transforming maintenance from reactive repair to proactive defense and maximizing production continuity.',
metrics: {
first: { value: '95%+', label: 'Fault warning accuracy' },
second: { value: '60%', label: 'Unplanned downtime reduction' },
third: { value: '72 hours', label: 'Advance prediction lead time' }
}
},
vision: {
title: 'Microscopic Insight',
subtitle: 'Machine Vision Inspection'
title: 'Insight into the Microscopic',
subtitle: 'Machine Vision Inspection',
detailTitle: 'Insight into the Microscopic',
detailSubTitle: 'Machine Vision Inspection',
detailDesc: 'Deep learning-based high-speed full-coverage defect detection for production lines, covering the entire quality inspection process including surface treatment, coating spraying, and welding assembly. This is not sampling inspection; every inch of product undergoes AI pixel-by-pixel comparison, completing defect identification and classification in milliseconds, ensuring zero compromise on product quality.',
metrics: {
first: { value: 'Milliseconds', label: 'Inspection speed' },
second: { value: '95%', label: 'Missed detection rate reduction' },
third: { value: '99.5%+', label: 'Identification accuracy' }
}
},
qa: {
title: 'Instant Answers',
subtitle: 'Intelligent Q&A Knowledge Base'
subtitle: 'Intelligent Q&A Knowledge Base',
detailTitle: 'Instant Answers',
detailSubTitle: 'Intelligent Q&A Knowledge Base',
detailDesc: 'An AI-powered Q&A engine built on enterprise private knowledge bases, supporting natural language queries for business data, technical specifications, and policy documents. Every answer is automatically annotated with authoritative sources, ensuring information is traceable and verifiable. Expert-level knowledge is no longer locked in documents but available on demand, like asking a colleague.',
metrics: {
first: { value: '<3 sec', label: 'Response time' },
second: { value: '90%', label: 'Expert knowledge coverage' },
third: { value: '5x', label: 'Decision-making efficiency improvement' }
}
},
protection: {
title: 'Continuous Protection',
subtitle: 'Intelligent Device Monitoring'
title: 'Full Protection',
subtitle: 'Intelligent Device Monitoring',
detailTitle: 'Full Protection',
detailSubTitle: 'Intelligent Equipment Monitoring',
detailDesc: 'Building independent monitoring strategies for each device, with 3D visualization presenting real-time operating status. AI automatically identifies anomalies and highlights fault locations, while synchronously outputting detailed diagnostic recommendations and troubleshooting steps. Operations staff are liberated from being glued to screens hunting for issues; the system proactively tells you where the issue is and how to fix it.',
metrics: {
first: { value: '1000+', label: 'Monitoring points supported' },
second: { value: '<1 sec', label: 'Alert response' },
third: { value: '40%', label: 'Maintenance cost reduction' }
}
},
document: {
title: 'Document Intelligence',
subtitle: 'Smart Document Parsing'
title: 'Read and Comprehend',
subtitle: 'Intelligent Document Parsing',
detailTitle: 'Read and Comprehend',
detailSubTitle: 'Intelligent Document Parsing',
detailDesc: 'Supporting one-click parsing of multiple formats including PDF, Word, and scanned documents. AI automatically extracts key clauses, qualification requirements, scoring criteria, and other core information for structured presentation. This is not simple OCR text recognition; it understands document semantics, judges information importance, and highlights risk points, making the core content of lengthy documents clear at a glance.',
metrics: {
first: { value: '98%+', label: 'Parsing accuracy' },
second: { value: '85%', label: 'Manual review time reduction' },
third: { value: 'Zero omission', label: 'Key information' }
}
},
edge: {
title: 'Edge Intelligence',
subtitle: 'Edge AI Inference'
title: 'On-Site Intelligence',
subtitle: 'Edge AI Inference',
detailTitle: 'On-Site Intelligence',
detailSubTitle: 'Edge AI Inference',
detailDesc: 'Deploying AI inference capabilities at the edge of production sites, achieving millisecond-level real-time decisions through lightweight algorithms. Data does not need to be transmitted back to the cloud; sensing to judgment is completed locally, ensuring data never leaves the facility and zero-latency response. Redundant architecture guarantees 7x24 continuous operation with uninterrupted production line inspection.',
metrics: {
first: { value: '<50ms', label: 'Inference latency' },
second: { value: 'Local', label: 'Data processed on site' },
third: { value: '7x24h', label: 'Continuous operation' }
}
}
},
cases: {

View File

@ -37,35 +37,99 @@ export default {
solutionCards: {
collaboration: {
title: '群智协作',
subtitle: '多智能体协同'
subtitle: '多智能体协同',
detailTitle: '群智协作',
detailSubTitle: '多智能体协同',
detailDesc: '多个AI智能体按角色分工自主完成信息采集、数据分析、逻辑推理与结论生成的全流程协作。支持动态任务分配与上下文共享将复杂业务决策从"人串联"变为"智能体并行",大幅压缩高知识密度工作的完成周期。',
metrics: {
first: { value: '10倍+', label: '知识工作效率提升' },
second: { value: '多源', label: '信息实时融合' },
third: { value: '高复杂度', label: '决策场景适配' }
}
},
spatial: {
title: '空间觉醒',
subtitle: 'GIS智能分析'
subtitle: 'GIS智能分析',
detailTitle: '空间觉醒',
detailSubTitle: 'GIS智能分析',
detailDesc: '融合地理信息系统、卫星遥感影像、无人机航拍等多维空间数据AI自动识别地物边界、叠加分析空间关系、校验合规约束。将传统需要人工逐层比对的地理空间判断升级为分钟级自动完成的空间智能决策。',
metrics: {
first: { value: '分钟级', label: '空间分析' },
second: { value: '90%+', label: '违规识别准确率' },
third: { value: '70%', label: '人力成本节省' }
}
},
predictive: {
title: '先见一步',
subtitle: '智能预测引擎'
subtitle: '智能预测引擎',
detailTitle: '先见一步',
detailSubTitle: '智能预测引擎',
detailDesc: '融合机理模型与数据驱动模型通过实时采集设备运行参数AI自主识别早期异常特征并精准预测剩余使用寿命。在故障真正发生前72小时发出预警让运维从"事后抢修"转变为"事前防御",最大化保障生产连续性。',
metrics: {
first: { value: '95%+', label: '故障预警准确率' },
second: { value: '60%', label: '非计划停机减少' },
third: { value: '72小时', label: '预测提前量' }
}
},
vision: {
title: '洞察入微',
subtitle: '机器视觉检测'
subtitle: '机器视觉检测',
detailTitle: '洞察入微',
detailSubTitle: '机器视觉检测',
detailDesc: '基于深度学习的高速产线全幅面缺陷检测能力覆盖表面处理、涂层喷涂、焊接装配等全流程质检环节。不是抽样检查而是每一寸产品都经过AI逐像素比对在毫秒级时间内完成缺陷识别与分类确保出厂品质零妥协。',
metrics: {
first: { value: '毫秒级', label: '检测速度' },
second: { value: '95%', label: '漏检率降低' },
third: { value: '99.5%+', label: '识别准确率' }
}
},
qa: {
title: '一问即达',
subtitle: '智能问答知识库'
subtitle: '智能问答知识库',
detailTitle: '一问即达',
detailSubTitle: '智能问答知识库',
detailDesc: '基于企业私有知识库构建的AI问答引擎支持自然语言直接查询业务数据、技术规范、制度文件。每条回答自动标注权威来源信息可追溯、可验证。让专家级知识不再锁在文档里而是像问同事一样即问即答。',
metrics: {
first: { value: '<3秒', label: '响应速度' },
second: { value: '90%', label: '专家知识覆盖率' },
third: { value: '5倍', label: '决策效率提升' }
}
},
protection: {
title: '全程守护',
subtitle: '设备智能监控'
subtitle: '设备智能监控',
detailTitle: '全程守护',
detailSubTitle: '设备智能监控',
detailDesc: '为每台设备构建独立监控策略通过三维可视化实时呈现运行状态。AI自动识别异常并高亮定位故障点同步输出详细诊断建议和排查步骤。将运维人员从"盯屏找问题"解放出来,让系统主动告诉你问题在哪、怎么修。',
metrics: {
first: { value: '1000+', label: '监控点位支持' },
second: { value: '<1秒', label: '告警响应' },
third: { value: '40%', label: '运维成本降低' }
}
},
document: {
title: '触文即懂',
subtitle: '智能文档解析'
subtitle: '智能文档解析',
detailTitle: '触文即懂',
detailSubTitle: '智能文档解析',
detailDesc: '支持PDF、Word、扫描件等多种格式一键解析AI自动提取关键条款、资质要求、评分细则等核心信息并结构化呈现。不是简单的OCR文字识别而是理解文档语义、判断信息重要性、标注风险点让冗长文档的核心内容一目了然。',
metrics: {
first: { value: '98%+', label: '解析准确率' },
second: { value: '85%', label: '人工核查时间减少' },
third: { value: '零遗漏', label: '关键信息' }
}
},
edge: {
title: '端侧即算',
subtitle: '边缘AI推理'
subtitle: '边缘AI推理',
detailTitle: '端侧即算',
detailSubTitle: '边缘AI推理',
detailDesc: '将AI推理能力部署在生产现场边缘侧通过轻量化算法实现毫秒级实时决策。数据无需回传云端在本地即可完成从感知到判断的全流程确保数据不出厂、响应零延迟。冗余架构设计保障7×24小时持续运行产线检测永不中断。',
metrics: {
first: { value: '<50ms', label: '推理延迟' },
second: { value: '本地', label: '数据处理不出厂' },
third: { value: '7×24h', label: '持续运行' }
}
}
},
cases: {

View File

@ -391,7 +391,7 @@ export default {
const userId = sessionStorage.getItem('userId');
if (!userId) {
this.$message.warning('请先登录');
this.$router.push('/login');
window.dispatchEvent(new Event('kboss-open-login-dialog'));
return;
}
this.messageCenterVisible = true;

View File

@ -122,6 +122,27 @@ export default {
box-sizing: border-box;
}
}
::v-deep .happy-scroll-strip,
::v-deep .happy-scroll-bar,
::v-deep .happy-scroll-rail {
display: none !important;
opacity: 0 !important;
width: 0 !important;
}
::v-deep .happy-scroll-content,
::v-deep .happy-scroll-container {
scrollbar-width: none;
-ms-overflow-style: none;
}
::v-deep .happy-scroll-content::-webkit-scrollbar,
::v-deep .happy-scroll-container::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
}
.sidebar-collapse-btn {

View File

@ -610,7 +610,7 @@ export const asyncRoutes = [
path: "/newsLog",
component: Layout,
meta: {
title: "新闻动态",
title: "企业动态",
fullPath: "/newsLog",
noCache: true,
icon: "el-icon-document",
@ -622,7 +622,7 @@ export const asyncRoutes = [
component: () => import('@/views/homePage/news/newsLog.vue'),
name: 'NewsLog',
meta: {
title: "新闻动态",
title: "企业动态",
fullPath: "/newsLog",
noCache: true,
roles: ["运营"]
@ -855,6 +855,18 @@ export const asyncRoutes = [
name: "detail",
hidden: true,
meta: { title: "详情", cache: true },
}, {
path: "news",
component: () => import("@/views/homePage/news/newsView.vue"),
name: "homePageNewsLegacy",
hidden: true,
meta: { title: "企业动态", cache: true },
}, {
path: "news/detail/:id",
component: () => import("@/views/homePage/news/NewsDetail.vue"),
name: "homePageNewsDetailLegacy",
hidden: true,
meta: { title: "新闻详情", cache: true },
}, {
path: "hospital",
component: () => import("@/views/homePage/solve/hospital/index.vue"),

View File

@ -165,9 +165,16 @@ service.interceptors.response.use(
isRedirectingToLogin = true
saveLoginRedirectPath()
showErrorMessage('登录已过期,请重新登录')
router.push({path: '/login'}).finally(() => {
const currentPath = (router.currentRoute && router.currentRoute.path) || ''
const isHomePage = currentPath.includes('/homePage') || currentPath.includes('/ncmatchHome')
if (isHomePage) {
window.dispatchEvent(new Event('kboss-open-login-dialog'))
isRedirectingToLogin = false
})
} else {
router.push({path: '/login'}).finally(() => {
isRedirectingToLogin = false
})
}
}
// window.location.href = 'https://www.opencomputing.cn/'
} else if (code == 403) {

View File

@ -13,8 +13,7 @@
<div class="brand-area">
<div class="brand-text">
<h2>{{ activeTab === 'login' ? '欢迎回来' : '创建账号' }}</h2>
<p>{{ activeTab === 'login' ? '登录后继续使用智能服务' : '注册开启全新体验' }}</p>
<h2>{{ activeTab === 'login' ? '欢迎登录' : '创建账号' }}</h2>
</div>
</div>

View File

@ -39,7 +39,7 @@ export default {
},
methods: {
goLoginPage() {
this.$router.push('/login')
window.dispatchEvent(new Event('kboss-open-login-dialog'))
},
goRegisterPage() {
this.$router.push('/register')

View File

@ -407,7 +407,7 @@ export default {
window.open('http://tmpweb.opencomputing.cn/col.jsp?id=136')
},
login() {
this.$router.push('/login')
window.dispatchEvent(new Event('kboss-open-login-dialog'))
},
register() {
this.$router.push('/registrationPage')

View File

@ -367,9 +367,11 @@ export default Vue.extend({
mounted() {
this.nick_name = sessionStorage.getItem('username') || ''
window.addEventListener('kboss-open-ai-chat', this.handleExternalAIOpen)
window.addEventListener('kboss-open-login-dialog', this.handleExternalLoginOpen)
},
beforeDestroy() {
window.removeEventListener('kboss-open-ai-chat', this.handleExternalAIOpen)
window.removeEventListener('kboss-open-login-dialog', this.handleExternalLoginOpen)
this.stopAIPanelDrag()
},
methods: {
@ -519,17 +521,10 @@ export default Vue.extend({
this.userId = sessionStorage.getItem('userId')
},
goLogin() {
this.closeProductPanelImmediate()
if (this.$route && this.$route.fullPath && !this.$route.fullPath.includes('/login')) {
sessionStorage.setItem('loginRedirectPath', this.$route.fullPath)
}
const fallback = `${window.location.origin}${window.location.pathname}#/login?fromPath=homePage`
this.$router.replace({ path: '/login', query: { fromPath: 'homePage' } }).catch(() => {})
window.setTimeout(() => {
if (!window.location.hash.includes('/login')) {
window.location.href = fallback
}
}, 220)
this.openLoginDialog()
},
handleExternalLoginOpen() {
this.openLoginDialog()
},
handleMessageClick() {
if (this.loginState) {

View File

@ -255,13 +255,7 @@ export default {
submitCreate() {
if (!this.loginState) {
this.$message.warning('请先登录后再创建实例')
this.$router.push({
path: '/login',
query: {
fromPath: 'computeMarketCreateInstance',
redirect: this.$route.fullPath
}
})
window.dispatchEvent(new Event('kboss-open-login-dialog'))
return
}

View File

@ -455,7 +455,7 @@ export default {
window.open('http://tmpweb.opencomputing.cn/col.jsp?id=136')
},
login() {
this.$router.push('/login')
window.dispatchEvent(new Event('kboss-open-login-dialog'))
},
register() {
this.$router.push('/registrationPage')

View File

@ -359,7 +359,7 @@ export default Vue.extend({
}
})
} else {
this.$router.push('/login')
window.dispatchEvent(new Event('kboss-open-login-dialog'))
}
},
initData() {
@ -418,14 +418,14 @@ export default Vue.extend({
if (this.loginState) {
this.$router.push('/ncmatchHome/favoriteBox')
} else {
this.$router.push('/login')
window.dispatchEvent(new Event('kboss-open-login-dialog'))
}
},
goHistory() {
if (this.loginState) {
this.$router.push('/ncmatchHome/historyBox')
} else {
this.$router.push('/login')
window.dispatchEvent(new Event('kboss-open-login-dialog'))
}
}
}

View File

@ -115,7 +115,7 @@
</section>
<!-- 企业动态 -->
<!-- <section id="news" class="news-section">
<section id="news" class="news-section">
<div class="news-glow news-glow--left"></div>
<div class="news-glow news-glow--right"></div>
<div class="news-glow news-glow--center"></div>
@ -149,7 +149,7 @@
</article>
</div>
</div>
</section> -->
</section>
<Talk></Talk>
</div>
@ -338,13 +338,26 @@ export default {
'触文即懂': 'document',
'端侧即算': 'edge'
}
const metricKeys = ['first', 'second', 'third']
return this.solutionCards.map((card) => {
const key = keyMap[card.title]
if (!key) return card
return {
...card,
title: this.$t(`home.solutionCards.${key}.title`),
subtitle: this.$t(`home.solutionCards.${key}.subtitle`)
subtitle: this.$t(`home.solutionCards.${key}.subtitle`),
detailTitle: this.$t(`home.solutionCards.${key}.detailTitle`),
detailSubTitle: this.$t(`home.solutionCards.${key}.detailSubTitle`),
detailDesc: this.$t(`home.solutionCards.${key}.detailDesc`),
metrics: card.metrics.map((metric, index) => {
const metricKey = metricKeys[index]
if (!metricKey) return metric
return {
...metric,
value: this.$t(`home.solutionCards.${key}.metrics.${metricKey}.value`),
label: this.$t(`home.solutionCards.${key}.metrics.${metricKey}.label`)
}
})
}
})
},
@ -840,7 +853,7 @@ body.dark-theme .bg-orb {
border-radius: inherit;
display: flex;
flex-direction: column;
justify-content: space-between;
justify-content: flex-start;
color: #1f2937;
text-align: left;
background: transparent;
@ -850,6 +863,18 @@ body.dark-theme .bg-orb {
pointer-events: none;
transform: translateY(16px);
transition: all 0.25s ease;
overflow-x: hidden;
overflow-y: auto;
box-sizing: border-box;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(37, 99, 235, 0.18);
border-radius: 999px;
}
strong {
margin-bottom: 8px;
@ -872,23 +897,24 @@ body.dark-theme .bg-orb {
p {
margin: 0;
color: #475569;
font-size: 13px;
line-height: 1.7;
font-size: 12px;
line-height: 1.62;
letter-spacing: 0;
}
ul {
margin: 18px 0 0;
margin: 16px 0 0;
padding: 0;
list-style: none;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
gap: 8px;
}
li {
min-height: 72px;
padding: 10px 6px 9px;
min-width: 0;
min-height: 74px;
padding: 9px 6px 8px;
border: 1px solid rgba(186, 220, 255, 0.72);
border-radius: 14px;
display: flex;
@ -901,21 +927,26 @@ body.dark-theme .bg-orb {
b {
display: block;
max-width: 100%;
color: #2563eb;
font-size: 26px;
line-height: 1.1;
font-size: 22px;
line-height: 1.12;
font-weight: 800;
letter-spacing: -0.01em;
white-space: nowrap;
white-space: normal;
overflow-wrap: anywhere;
word-break: normal;
}
small {
display: block;
max-width: 100%;
margin-top: 7px;
color: #64748b;
font-size: 12px;
line-height: 1.25;
font-size: 11px;
line-height: 1.2;
font-weight: 700;
overflow-wrap: anywhere;
}
&::before {

View File

@ -191,7 +191,7 @@ export default Vue.extend({
}
})
} else {
this.$router.push('/login')
window.dispatchEvent(new Event('kboss-open-login-dialog'))
}
},

View File

@ -359,7 +359,7 @@ export default Vue.extend({
}
})
} else {
this.$router.push('/login')
window.dispatchEvent(new Event('kboss-open-login-dialog'))
}
},
initData() {
@ -418,14 +418,14 @@ export default Vue.extend({
if (this.loginState) {
this.$router.push('/ncmatchHome/favoriteBox')
} else {
this.$router.push('/login')
window.dispatchEvent(new Event('kboss-open-login-dialog'))
}
},
goHistory() {
if (this.loginState) {
this.$router.push('/ncmatchHome/historyBox')
} else {
this.$router.push('/login')
window.dispatchEvent(new Event('kboss-open-login-dialog'))
}
}
}

View File

@ -454,7 +454,7 @@ export default {
},
sendInfo(type) {
if (!this.loginState) {
this.$router.push('/login')
window.dispatchEvent(new Event('kboss-open-login-dialog'))
return
}

View File

@ -1,21 +1,32 @@
<template>
<el-dialog
:visible.sync="dialogVisible"
:title="dialogTitle"
:fullscreen="true"
append-to-body
custom-class="article-editor-dialog"
:close-on-click-modal="false"
:before-close="handleBeforeClose"
@open="handleOpen"
@close="handleDialogClose"
>
<div class="article-editor">
<div slot="title" class="article-dialog-title">
<button type="button" class="dialog-back-btn" @click="requestClose">
<i class="el-icon-arrow-left"></i>
返回
</button>
<span>{{ dialogTitle }}</span>
</div>
<div class="article-editor" :class="{ 'is-editor-fullscreen': editorFullscreen }">
<div class="article-editor__left">
<div class="article-editor__form">
<el-form :model="form" label-width="76px" size="small">
<el-form-item label="文章标题">
<el-form-item>
<template slot="label"><span class="required-star">*</span>文章标题</template>
<el-input v-model.trim="form.title" placeholder="请输入文章标题" />
</el-form-item>
<el-form-item label="文章摘要">
<el-form-item>
<template slot="label"><span class="required-star">*</span>文章摘要</template>
<el-input
v-model.trim="form.summary"
type="textarea"
@ -26,7 +37,8 @@
/>
</el-form-item>
<div class="article-editor__inline">
<el-form-item label="文章类型">
<el-form-item>
<template slot="label"><span class="required-star">*</span>文章类型</template>
<el-select v-model="form.type" placeholder="请选择文章类型">
<el-option label="企业动态" value="企业动态" />
<el-option label="产品动态" value="产品动态" />
@ -34,7 +46,8 @@
<el-option label="活动资讯" value="活动资讯" />
</el-select>
</el-form-item>
<el-form-item label="发布时间">
<el-form-item>
<template slot="label"><span class="required-star">*</span>发布时间</template>
<el-date-picker
v-model="form.publishTime"
type="date"
@ -43,7 +56,8 @@
/>
</el-form-item>
</div>
<el-form-item label="封面配图">
<el-form-item>
<template slot="label"><span class="required-star">*</span>封面配图</template>
<div class="cover-row">
<el-button type="primary" size="small" @click="$refs.coverInput.click()">上传图片</el-button>
<el-input
@ -61,6 +75,7 @@
<div v-if="dialogVisible" class="wang-editor-wrap">
<Toolbar
class="wang-toolbar"
:editor="editor"
@ -104,9 +119,9 @@
<div slot="footer" class="article-editor-footer">
<span class="editor-status">{{ dirty ? '未保存' : '已同步' }}</span>
<div>
<el-button size="small" @click="dialogVisible = false">取消</el-button>
<el-button size="small" @click="handleSave('draft')">保存草稿</el-button>
<el-button type="primary" size="small" @click="handleSave('published')">发布文章</el-button>
<el-button size="small" @click="requestClose">取消</el-button>
<el-button size="small" @click="handleSave('0')">保存草稿</el-button>
<el-button type="primary" size="small" @click="handlePublishConfirm">保存并发布</el-button>
</div>
</div>
</el-dialog>
@ -115,6 +130,7 @@
<script>
import '@wangeditor/editor/dist/css/style.css'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { reqUploadNewsImage } from '@/api/newsapi/newsapi'
const normalizeImageUrl = (url) => {
if (!url) return ''
@ -122,36 +138,6 @@ const normalizeImageUrl = (url) => {
return `${window.location.origin}/idfile?path=${url}`
}
const compressImageToBase64 = (file, options = {}) => {
const maxWidth = options.maxWidth || 900
const maxHeight = options.maxHeight || 900
const quality = options.quality || 0.65
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const image = new Image()
image.onload = () => {
const ratio = Math.min(maxWidth / image.width, maxHeight / image.height, 1)
const width = Math.round(image.width * ratio)
const height = Math.round(image.height * ratio)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = width
canvas.height = height
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, width, height)
ctx.drawImage(image, 0, 0, width, height)
resolve(canvas.toDataURL('image/jpeg', quality))
}
image.onerror = reject
image.src = reader.result
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
export default {
name: 'ArticleEditorDialog',
components: { Editor, Toolbar },
@ -182,15 +168,11 @@ export default {
uploadImage: {
customUpload: async (file, insertFn) => {
try {
// Base64
const url = await compressImageToBase64(file, {
maxWidth: 900,
maxHeight: 900,
quality: 0.65
})
insertFn(url, file.name, url)
//
const url = await this.uploadNewsImage(file)
insertFn(normalizeImageUrl(url), file.name, normalizeImageUrl(url))
} catch (error) {
this.$message.error('正文图片读取失败')
this.$message.error('正文图片上传失败')
}
}
}
@ -198,6 +180,9 @@ export default {
},
//
coverPreview: '',
imageUploading: false,
editorFullscreen: false,
editorFullscreenObserver: null,
//
previewWidth: 400,
//
@ -255,6 +240,8 @@ export default {
}
//
this.removeResizeListeners()
this.stopObserveEditorFullscreen()
this.editorFullscreen = false
},
methods: {
createEmptyForm() {
@ -282,33 +269,131 @@ export default {
if (!this.form.publishTime) this.form.publishTime = this.today
//
this.coverPreview = this.form.coverUrl || ''
this.editorFullscreen = false
this.$nextTick(() => {
//
this.dirty = false
this.startObserveEditorFullscreen()
})
},
handleEditorCreated(editor) {
// 使
this.editor = Object.seal(editor)
},
handleDialogClose() {
this.stopObserveEditorFullscreen()
this.editorFullscreen = false
},
requestClose() {
this.handleBeforeClose(() => {
this.dialogVisible = false
})
},
handleBeforeClose(done) {
if (!this.dirty) {
done()
return
}
this.$confirm('当前编辑内容尚未保存,是否保存为草稿?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
customClass: 'news-confirm-box news-confirm-box--warning',
confirmButtonClass: 'news-confirm-btn news-confirm-btn--confirm',
cancelButtonClass: 'news-confirm-btn news-confirm-btn--cancel'
}).then(() => {
this.handleSave('0')
}).catch(() => {
done()
})
},
handlePublishConfirm() {
this.$confirm('请确认该文章不包含敏感词汇。', '确认发布企业动态文章?', {
confirmButtonText: '确定发布',
cancelButtonText: '取消',
type: 'success',
customClass: 'news-confirm-box news-confirm-box--success',
confirmButtonClass: 'news-confirm-btn news-confirm-btn--confirm is-success',
cancelButtonClass: 'news-confirm-btn news-confirm-btn--cancel'
}).then(() => {
this.handleSave('1')
}).catch(() => {})
},
isEditorFullscreenActive() {
const fullscreenElement = document.fullscreenElement || document.webkitFullscreenElement
if (fullscreenElement) return true
return !!document.querySelector('.w-e-full-screen-container, .w-e-full-screen, .w-e-text-container-full-screen')
},
updateEditorFullscreenState() {
this.editorFullscreen = this.isEditorFullscreenActive()
},
startObserveEditorFullscreen() {
this.stopObserveEditorFullscreen()
this.updateEditorFullscreenState()
this.editorFullscreenObserver = new MutationObserver(() => {
this.updateEditorFullscreenState()
})
this.editorFullscreenObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'style']
})
},
stopObserveEditorFullscreen() {
if (this.editorFullscreenObserver) {
this.editorFullscreenObserver.disconnect()
this.editorFullscreenObserver = null
}
},
getUploadUrl(res) {
const pickUrl = (data) => {
if (!data) return ''
if (typeof data === 'string') return data
if (typeof data === 'object') {
return data.file_path ||
data.file_url ||
data.save_url ||
data.url ||
data.path ||
data.filePath ||
data.fileUrl ||
data.saveUrl ||
pickUrl(data.data)
}
return ''
}
return pickUrl(res && (res.data || res))
},
async uploadNewsImage(file) {
const formData = new FormData()
formData.append('file_data', file)
formData.append('domain_name', window.location.host)
const res = await reqUploadNewsImage(formData)
if (res && (res.status === true || res.status === 'true')) {
const url = this.getUploadUrl(res)
if (url) return url
}
throw new Error((res && (res.msg || res.message)) || '图片上传失败')
},
async handleCoverUpload(event) {
//
const file = event.target.files && event.target.files[0]
if (!file) return
//
this.form.coverName = file.name
this.imageUploading = true
try {
// Base64 coverUrl / cover_img
const base64 = await compressImageToBase64(file, {
maxWidth: 900,
maxHeight: 900,
quality: 0.68
})
this.coverPreview = base64
this.form.coverUrl = base64
// cover_img
const url = await this.uploadNewsImage(file)
this.form.coverUrl = url
this.coverPreview = normalizeImageUrl(url)
this.$message.success('封面图片上传成功')
} catch (error) {
this.$message.error('封面图片读取失败')
this.form.coverName = ''
this.$message.error('封面图片上传失败')
} finally {
this.imageUploading = false
// input change
event.target.value = ''
}
@ -384,6 +469,10 @@ export default {
this.$message.warning('请输入文章正文')
return
}
if (this.imageUploading) {
this.$message.warning('图片上传中,请稍后保存')
return
}
// status 稿
const payload = {
...this.form,
@ -402,6 +491,19 @@ export default {
</script>
<style scoped>
::v-deep .el-form-item__content{
/* width: 100% !important; */
margin-left: 88px !important;
}
::v-deep .el-form-item__label{
width: auto !important;
}
.required-star{
color: #ff0000;
font-size: 16px;
/* font-weight: 700; */
margin-right: 4px;
}
::v-deep .article-editor-dialog {
margin: 0 !important;
height: 100vh;
@ -415,6 +517,35 @@ export default {
border-bottom: 1px solid #edf0f5;
}
.article-dialog-title {
display: flex;
align-items: center;
gap: 14px;
color: #1f2937;
font-size: 16px;
font-weight: 700;
}
.dialog-back-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 10px;
color: #64748b;
font-size: 13px;
background: #fff;
border: 1px solid #d8e0ec;
border-radius: 999px;
cursor: pointer;
transition: all 0.2s ease;
}
.dialog-back-btn:hover {
color: #2563eb;
border-color: #bfdbfe;
background: #eff6ff;
}
::v-deep .article-editor-dialog .el-dialog__body {
flex: 1;
min-height: 0;
@ -474,6 +605,10 @@ export default {
background: #3b82f6;
}
.article-editor.is-editor-fullscreen .resize-handle {
display: none;
}
.article-editor__form {
padding: 16px 18px 4px 0;
border-bottom: 1px solid #edf0f5;

View File

@ -1,33 +1,34 @@
<template>
<div class="news-detail-page">
<div class="news-detail-shell">
<div class="news-detail-layout">
<button type="button" class="back-btn" @click="goBack">
<i class="el-icon-arrow-left"></i>
返回动态
</button>
<div class="news-detail-shell">
<article v-loading="loading" class="detail-card">
<div v-if="article.id" class="detail-content">
<div class="detail-meta">
<span class="detail-tag">{{ article.articleType }}</span>
<time>{{ article.publishTime }}</time>
</div>
<article v-loading="loading" class="detail-card">
<div v-if="article.id" class="detail-content">
<div class="detail-meta">
<span class="detail-tag">{{ article.articleType }}</span>
<time>{{ article.publishTime }}</time>
<h1>{{ article.title }}</h1>
<!-- <p v-if="article.summary" class="summary">{{ article.summary }}</p> -->
<!--
<div v-if="article.coverImg" class="cover-wrap">
<img :src="article.coverImg" :alt="article.title">
</div> -->
<div class="article-body" v-html="article.content"></div>
</div>
<h1>{{ article.title }}</h1>
<p v-if="article.summary" class="summary">{{ article.summary }}</p>
<div v-if="article.coverImg" class="cover-wrap">
<img :src="article.coverImg" :alt="article.title">
<div v-else-if="!loading" class="empty-state">
<i class="el-icon-document"></i>
<p>暂无新闻详情</p>
</div>
<div class="article-body" v-html="article.content"></div>
</div>
<div v-else-if="!loading" class="empty-state">
<i class="el-icon-document"></i>
<p>暂无新闻详情</p>
</div>
</article>
</article>
</div>
</div>
</div>
</template>
@ -40,6 +41,7 @@ export default {
data() {
return {
loading: false,
fallbackNewsImage: require('@/assets/image/news.jpg'),
article: {
id: '',
title: '',
@ -115,6 +117,9 @@ export default {
})
if (res && (res.status === true || res.status === 'true')) {
this.article = this.normalizeArticle(this.getResponseDetail(res))
this.$nextTick(() => {
this.bindArticleImageFallback()
})
return
}
this.$message.error((res && (res.message || res.msg)) || '新闻详情加载失败')
@ -123,6 +128,16 @@ export default {
} finally {
this.loading = false
}
},
bindArticleImageFallback() {
const images = this.$el.querySelectorAll('.article-body img, .cover-wrap img')
images.forEach((image) => {
image.onerror = () => {
if (image.src !== this.fallbackNewsImage) {
image.src = this.fallbackNewsImage
}
}
})
}
}
}
@ -135,32 +150,49 @@ export default {
background: linear-gradient(180deg, #eff6ff 0%, #fff 55%, #f8fbff 100%);
}
.news-detail-layout {
width: 100%;
max-width: 1000px;
display: grid;
grid-template-columns: 132px minmax(0, 677px);
column-gap: 12px;
align-items: start;
margin: 0 auto;
}
.news-detail-shell {
width: 100%;
max-width: 1080px;
margin: 0 auto;
max-width: 677px;
margin: 0;
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 20px;
padding: 9px 14px;
justify-content: flex-start;
margin-top: 0;
padding: 5px 8px;
color: #2563eb;
background: #fff;
border: 1px solid rgba(37, 99, 235, 0.16);
font-size: 17px;
font-weight: 500;
// background: #fff;
// border: 1px solid rgba(37, 99, 235, 0.16);
border-radius: 999px;
cursor: pointer;
background: transparent;
border: 0;
}
.detail-card {
min-height: 420px;
padding: 44px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(226, 232, 240, 0.9);
border-radius: 24px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.08);
padding-left: 44px;
padding-right: 44px;
padding-bottom: 44px;
// background: rgba(255, 255, 255, 0.92);
// border: 1px solid rgba(226, 232, 240, 0.9);
// border-radius: 24px;
// box-shadow: 0 24px 70px rgba(15, 23, 42, 0.08);
}
.detail-meta {
@ -211,9 +243,13 @@ h1 {
}
.article-body ::v-deep img {
max-width: 100%;
height: auto;
display: block;
width: auto !important;
max-width: 100% !important;
height: auto !important;
margin: 18px auto;
border-radius: 12px;
object-fit: contain;
}
.empty-state {
@ -236,6 +272,14 @@ h1 {
padding: 80px 14px 40px;
}
.news-detail-layout {
display: block;
}
.back-btn {
margin-bottom: 16px;
}
.detail-card {
padding: 24px;
}

View File

@ -1,7 +1,7 @@
<template>
<div class="article-admin-page">
<div class="page-title-wrap">
<h1>企业文章</h1>
<h1>企业动态</h1>
</div>
<div class="stats-grid">
@ -66,7 +66,16 @@
{{ getRowNo(scope.$index) }}
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="260" show-overflow-tooltip>
<el-table-column
prop="title"
label="标题"
min-width="240"
align="left"
header-align="left"
class-name="title-column"
label-class-name="title-column-header"
show-overflow-tooltip
>
<template slot-scope="scope">
<span class="article-title">{{ scope.row.title }}</span>
</template>
@ -83,6 +92,7 @@
</span>
</template>
</el-table-column>
<el-table-column prop="updateTime" label="创建时间" width="170" />
<el-table-column prop="publishTime" label="发布时间" width="140" />
<el-table-column label="阅读数据" width="130">
<template slot-scope="scope">
@ -118,7 +128,7 @@
class="success-text"
@click="publishArticle(scope.row)"
>
上架
发布
</el-button>
<el-button
type="text"
@ -228,7 +238,7 @@ export default {
//
const classMap = {
企业动态: 'type-blue',
产品动态: 'type-purple',
产品动态: 'type-green',
行业洞察: 'type-amber',
活动资讯: 'type-violet'
}
@ -256,7 +266,7 @@ export default {
},
getApiStatus(status) {
// 1 0 稿/
return status === 'published' ? '1' : '0'
return String(status) === '1' || status === 'published' ? '1' : '0'
},
getViewStatus(status) {
// 使
@ -286,6 +296,7 @@ export default {
type: row.article_type || row.type || '企业动态',
status: this.getViewStatus(row.status),
publishTime: row.publish_time || row.publishTime || '',
updateTime: row.update_time || row.updateTime || '',
views: row.read_count || row.views || 0,
content: row.content || '',
coverUrl: row.cover_img || row.coverUrl || '',
@ -321,19 +332,25 @@ export default {
title: article.title,
article_type: article.type,
summary: article.summary,
update_time: article.update_time || article.updateTime,
publish_time: article.publish_time || article.publishTime,
cover_img: article.coverUrl,
content: article.content,
status: this.getApiStatus(article.status)
}
},
async confirmAction(title, message, type = 'warning') {
async confirmAction(title, message, type = 'warning', confirmButtonText = '确定') {
//
const isDanger = type === 'error'
const isSuccess = type === 'success'
try {
await this.$confirm(message, title, {
confirmButtonText: '确定',
confirmButtonText,
cancelButtonText: '取消',
type
type,
customClass: `news-confirm-box ${isDanger ? 'news-confirm-box--danger' : isSuccess ? 'news-confirm-box--success' : 'news-confirm-box--warning'}`,
confirmButtonClass: `news-confirm-btn news-confirm-btn--confirm ${isDanger ? 'is-danger' : isSuccess ? 'is-success' : ''}`,
cancelButtonClass: 'news-confirm-btn news-confirm-btn--cancel'
})
return true
} catch (error) {
@ -380,7 +397,7 @@ export default {
return
}
//
this.$message.success(article.status === 'published' ? '发布成功' : '草稿已保存')
this.$message.success(this.getApiStatus(article.status) === '1' ? '发布成功' : '草稿已保存')
this.getArticleList()
} catch (error) {
//
@ -389,7 +406,7 @@ export default {
},
async publishArticle(row) {
//
const confirmed = await this.confirmAction('确认上架', `确定要上架「${row.title}」吗?`)
const confirmed = await this.confirmAction('确认发布企业动态文章?', '请确认该文章不包含敏感词汇。', 'success', '确定发布')
if (!confirmed) return
try {
//
@ -400,16 +417,16 @@ export default {
return
}
//
this.$message.success('上架成功')
this.$message.success('发布成功')
this.getArticleList()
} catch (error) {
//
this.$message.error('上架失败')
this.$message.error('发布失败')
}
},
async unpublishArticle(row) {
//
const confirmed = await this.confirmAction('确认下架', `确定要下架「${row.title}」吗?`)
const confirmed = await this.confirmAction('确定要下架这篇文章吗?', '下架后将在企业动态页面隐藏。')
if (!confirmed) return
try {
//
@ -563,6 +580,11 @@ export default {
border-radius: 8px;
}
.filter-form ::v-deep .el-button--small {
height: 32px;
border-radius: 8px;
}
.table-card {
overflow: hidden;
}
@ -574,9 +596,18 @@ export default {
font-weight: 600;
}
.article-table ::v-deep .title-column .cell,
.article-table ::v-deep .title-column-header .cell {
justify-content: flex-start;
/* text-align: left !important; */
}
.article-title {
display: block;
width: 100%;
color: #1f2937;
font-weight: 600;
text-align: left;
}
.type-tag {
@ -592,9 +623,9 @@ export default {
background: #eff6ff;
}
.type-purple {
color: #7c3aed;
background: #f5f3ff;
.type-green {
color: #059669;
background: #ecfdf5;
}
.type-amber {
@ -603,8 +634,8 @@ export default {
}
.type-violet {
color: #8b5cf6;
background: #f5f3ff;
color: #db2777;
background: #fdf2f8;
}
.status-tag {

View File

@ -43,7 +43,7 @@
<span class="hover-detail">详情 <i class="el-icon-arrow-right"></i></span>
<div class="news-featured-inner">
<div class="news-featured-img">
<img :src="featuredNews.img || fallbackNewsImage" :alt="featuredNews.title">
<img :src="featuredNews.img || fallbackNewsImage" :alt="featuredNews.title" @error="handleImageError">
</div>
<div class="news-featured-body">
<div class="news-featured-date">
@ -71,7 +71,7 @@
<div class="news-item-glow"></div>
<span class="hover-detail">详情 <i class="el-icon-arrow-right"></i></span>
<div class="news-item-img">
<img :src="item.img || fallbackNewsImage" :alt="item.title">
<img :src="item.img || fallbackNewsImage" :alt="item.title" @error="handleImageError">
<div class="news-item-img-overlay"></div>
<span class="news-item-tag" :style="{ background: item.tagColor }">{{ item.tag }}</span>
</div>
@ -115,6 +115,7 @@ export default {
fallbackNewsImage: require('@/assets/image/news.jpg'),
activeCategory: 'all',
page: 1,
pageSize: 10,
total: 0,
filterTabs: [
@ -150,7 +151,17 @@ export default {
},
goNewsDetail(item) {
if (!item || !item.id) return
this.$router.push(`/homePage/news/detail/${item.id}`)
this.$router.push({
name: 'homePageNewsDetail',
params: { id: item.id }
}).catch(() => {
this.$router.push(`/homePage/news/detail/${item.id}`)
})
},
handleImageError(event) {
if (event && event.target && event.target.src !== this.fallbackNewsImage) {
event.target.src = this.fallbackNewsImage
}
},
handleCategoryChange(value) {
this.activeCategory = value

View File

@ -2,7 +2,7 @@
<div class="train-page">
<div class="grid-bg min-h-screen">
<main>
<div class="page-shell">
<div class="page-shell">
<section class="hero-section">
<div class="hero-content">
<div class="hero-eyebrow animate-fade-in-up">

View File

@ -2,7 +2,7 @@
<div class="mainBox">
<div class="header">
<div class="logowx"><img src="../../assets/kyy/simple_logonew.png" alt=""></div>
<div class="btnStyle" @click="$router.push('/login')"> 登录/注册</div>
<div class="btnStyle" @click="openLoginDialog"> 登录/注册</div>
</div>
<div class="banner" style="margin-top: 60px">
{{ $route.query.productType === '0' ? "供给详情" : "需求详情" }}
@ -87,7 +87,7 @@
</li>
<li>
<span style="margin-right: 15px" class="tag">产品单价:</span>
<span style="font-weight: 700;color: blue" @click="$router.push('/login')">登录后查看</span></li>
<span style="font-weight: 700;color: blue" @click="openLoginDialog">登录后查看</span></li>
</ul>
<ul v-else-if="$route.query.productType=='1'" class="detail-ul">
<li><span style="margin-right: 15px" class="tag">需求说明:</span>{{ dataObj.demanddesc }}</li>
@ -127,6 +127,9 @@ export default {
}
},
methods: {
openLoginDialog() {
window.dispatchEvent(new Event('kboss-open-login-dialog'))
},
computedWord(type) {
if (type === '1') {
return '机柜'

View File

@ -2,7 +2,7 @@
<div class="mainBox">
<div class="header">
<div class="logowx"><img src="../../assets/kyy/simple_logonew.png" alt=""></div>
<div class="btnStyle" @click="$router.push('/login')"> 登录/注册</div>
<div class="btnStyle" @click="openLoginDialog"> 登录/注册</div>
</div>
<div class="banner" style="background: #f8f9fe">
<div class="block">
@ -39,7 +39,7 @@
</ul>
</div>
<div style="background: #f8f9fe" class="lookMore">
<span class="word" @click="$router.push('/login')"> 查看更多</span>
<span class="word" @click="openLoginDialog"> 查看更多</span>
</div>
<div class="apS">
模型广场
@ -73,7 +73,7 @@
</ul>
<div class="lookMore">
<span class="word" @click="$router.push('/login')"> 查看更多</span>
<span class="word" @click="openLoginDialog"> 查看更多</span>
</div>
<div class="footerBox">
<!-- <div>-->
@ -147,6 +147,9 @@ export default {
},
methods: {
openLoginDialog() {
window.dispatchEvent(new Event('kboss-open-login-dialog'))
},
goDetail(item) {
this.$router.push({
path: "/wxDetailPage",

View File

@ -439,7 +439,7 @@
window.open('http://tmpweb.opencomputing.cn/col.jsp?id=136')
},
login() {
this.$router.push('/login')
window.dispatchEvent(new Event('kboss-open-login-dialog'))
},
register() {
this.$router.push('/registrationPage')

View File

@ -2,7 +2,7 @@
<el-dialog
custom-class="forgot-password-dialog"
:visible="visible"
width="420px"
width="540px"
:close-on-click-modal="false"
destroy-on-close
append-to-body
@ -45,7 +45,7 @@
<div slot="footer" class="forgot-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">确认重置</el-button>
<el-button class="confirm-btn" type="primary" :loading="submitting" @click="handleSubmit">确认重置</el-button>
</div>
</el-dialog>
</template>
@ -205,21 +205,21 @@ export default {
<style lang="scss">
.forgot-password-dialog {
border-radius: 16px;
border-radius: 30px;
overflow: hidden;
.el-dialog__header {
padding: 24px 28px 18px;
background: linear-gradient(135deg, #f4f8ff 0%, #ffffff 70%);
border-bottom: 1px solid #eef2f7;
padding: 40px 44px 20px;
background: #fff;
border-bottom: 0;
}
.el-dialog__body {
padding: 24px 28px 8px;
padding: 10px 44px 8px;
}
.el-dialog__footer {
padding: 14px 28px 24px;
padding: 12px 44px 36px;
}
.el-dialog__headerbtn {
@ -261,7 +261,7 @@ export default {
.forgot-form {
.el-form-item {
margin-bottom: 18px;
margin-bottom: 22px;
}
.el-form-item__label {
@ -272,9 +272,10 @@ export default {
}
.el-input__inner {
height: 42px;
border-color: #dfe5ef;
border-radius: 8px;
height: 48px;
border-color: #c8d3e2;
border-radius: 14px;
background: rgba(255, 255, 255, 0.94);
}
}
@ -288,11 +289,11 @@ export default {
.code-btn {
width: 118px;
height: 42px;
height: 48px;
color: #2f6bff;
background: #eef4ff;
border-color: #cfe0ff;
border-radius: 8px;
border-radius: 14px;
&:hover,
&:focus {
@ -305,12 +306,30 @@ export default {
.forgot-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
flex-direction: column;
gap: 12px;
.el-button {
min-width: 90px;
border-radius: 8px;
width: 100%;
height: 52px;
margin-left: 0;
border-radius: 999px;
font-size: 16px;
font-weight: 700;
}
.confirm-btn {
color: #fff;
background: #020617;
border-color: #020617;
box-shadow: 0 16px 34px rgba(2, 6, 23, 0.22);
&:hover,
&:focus {
color: #fff;
background: #020617;
border-color: #020617;
}
}
}
</style>

View File

@ -542,6 +542,8 @@
}
}
.model-table {
border-radius: 12px;
border
/deep/ .el-table__header th {
color: #475467;
background: #f8fbff;

View File

@ -303,6 +303,7 @@ export default {
.config-table {
margin-top: 10px;
color: #344054;
border-radius: 12px;
/deep/ th {
color: #475467;

View File

@ -847,41 +847,21 @@ export default {
}
});
} else {
this.$router.push({
path: "/login",
query: {
fromPath: 'productService',
type: 'bd',
listUrl: product.listUrl,
url: product.url
}
});
window.dispatchEvent(new Event('kboss-open-login-dialog'));
}
} else if (product.type === '阿里云') {
if (userId) {
await this.handleAliyunProductClick();
} else {
this.$router.push({
path: "/login",
query: {
fromPath: 'productService',
type: 'ali',
}
});
window.dispatchEvent(new Event('kboss-open-login-dialog'));
}
} else if (product.type === '智算' || product.type === '算力网络') {
if (product.name === '容器云') {
if (userId) {
this.$router.push('/product/productHome/k8s/createK8s');
} else {
this.$router.push({
path: "/login",
query: {
fromPath: 'productService',
type: 'rqy',
}
});
window.dispatchEvent(new Event('kboss-open-login-dialog'));
}
} else {
this.$router.push({
@ -904,7 +884,7 @@ export default {
<style lang="less" scoped>
.product-service-page {
margin: 0 auto;
padding: 82px 24px 24px;
padding: 0px 24px 24px;
background: #fff;
min-height: calc(100vh - 100px);

View File

@ -587,6 +587,7 @@ export default {
}
.api-key-table {
border-radius: 12px;
color: #606266;
background: transparent;

View File

@ -467,17 +467,17 @@ export default {
min-height: 100vh;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(64, 158, 255, 0.16), transparent 34%),
// radial-gradient(circle at top left, rgba(64, 158, 255, 0.16), transparent 34%),
linear-gradient(180deg, #f3f7ff 0%, #f7f9fc 48%, #ffffff 100%);
}
.usage-shell {
min-height: calc(100vh - 48px);
padding: 24px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid #edf1f7;
// padding: 24px;
// background: rgba(255, 255, 255, 0.92);
// border: 1px solid #edf1f7;
border-radius: 18px;
box-shadow: 0 12px 30px rgba(31, 45, 61, 0.06);
// box-shadow: 0 12px 30px rgba(31, 45, 61, 0.06);
}
.page-header,