```
feat(api): 添加OLT端口管理和设备发现功能 - 在check.py中更新discover_olt方法,使用scan_and_discover替代check_olt_devices - 在olt.py中添加get_olt_ports和toggle_olt_port接口,支持获取和控制OLT端口状态 - 添加TogglePortRequest模型用于端口操作请求验证 - 更新SSHService类,新增get_olt_ports和toggle_olt_port方法 - 优化CheckService中的MAC地址索引逻辑,支持模型信息更新 ```
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: frontend-design
|
||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
||||
|
||||
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
||||
|
||||
## Design Thinking
|
||||
|
||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
||||
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
||||
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
||||
|
||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||
|
||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||
- Production-grade and functional
|
||||
- Visually striking and memorable
|
||||
- Cohesive with a clear aesthetic point-of-view
|
||||
- Meticulously refined in every detail
|
||||
|
||||
## Frontend Aesthetics Guidelines
|
||||
|
||||
Focus on:
|
||||
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
||||
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
||||
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
||||
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
||||
|
||||
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
||||
|
||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
||||
|
||||
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
||||
|
||||
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/frontend-design
|
||||
@@ -80,7 +80,7 @@ def discover_olt(olt_id: int, db: Session = Depends(get_db)):
|
||||
"""扫描单台 OLT 并将新发现的 MAC 自动入库关联"""
|
||||
try:
|
||||
service = CheckService(db)
|
||||
result = asyncio.run(service.check_olt_devices(olt_id))
|
||||
result = service.scan_and_discover(olt_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -400,3 +400,46 @@ def loopback_detection(db: Session = Depends(get_db)):
|
||||
|
||||
# 按原始顺序返回
|
||||
return [results_map[olt.id] for olt in olts]
|
||||
|
||||
|
||||
class TogglePortRequest(BaseModel):
|
||||
action: str # "shutdown" 或 "undo shutdown"
|
||||
|
||||
|
||||
@router.get("/devices/{olt_id}/ports")
|
||||
def get_olt_ports(olt_id: int, db: Session = Depends(get_db)):
|
||||
"""获取指定 OLT 的所有 Olt 端口状态"""
|
||||
from app.services.ssh_service import SSHService
|
||||
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||
if not olt:
|
||||
raise HTTPException(status_code=404, detail="OLT 不存在")
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
ports = ssh.get_olt_ports()
|
||||
return {"ports": ports}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
|
||||
@router.post("/devices/{olt_id}/ports/toggle")
|
||||
def toggle_olt_port(olt_id: int, body: TogglePortRequest, port_name: str, db: Session = Depends(get_db)):
|
||||
"""开启或关闭指定 OLT 端口"""
|
||||
from app.services.ssh_service import SSHService
|
||||
if body.action not in ("shutdown", "undo shutdown"):
|
||||
raise HTTPException(status_code=400, detail="action 必须为 shutdown 或 undo shutdown")
|
||||
olt = db.query(OLTDevice).filter(OLTDevice.id == olt_id).first()
|
||||
if not olt:
|
||||
raise HTTPException(status_code=404, detail="OLT 不存在")
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
ssh.toggle_olt_port(port_name, body.action)
|
||||
return {"message": f"端口 {port_name} 已{'关闭' if body.action == 'shutdown' else '开启'}"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
|
||||
@@ -39,17 +39,18 @@ class CheckService:
|
||||
online_count = 0
|
||||
offline_count = 0
|
||||
|
||||
# 全局 MAC → id 索引,只取需要的字段
|
||||
# 全局 MAC → (id, model) 索引
|
||||
existing = {
|
||||
row.mac_address.lower(): row.id
|
||||
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id).all()
|
||||
row.mac_address.lower(): (row.id, row.model)
|
||||
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id, ONUDevice.model).all()
|
||||
}
|
||||
|
||||
for mac, onu_info in onu_info_dict.items():
|
||||
onu_id = existing.get(mac)
|
||||
if onu_id is None:
|
||||
entry = existing.get(mac)
|
||||
if entry is None:
|
||||
continue # 不在库中,跳过
|
||||
|
||||
onu_id, onu_model = entry
|
||||
status = onu_info.status
|
||||
distance_m = parse_distance(onu_info.distance_str)
|
||||
if status == 'online':
|
||||
@@ -57,6 +58,9 @@ class CheckService:
|
||||
else:
|
||||
offline_count += 1
|
||||
|
||||
if onu_info.model and not onu_model:
|
||||
self.db.query(ONUDevice).filter(ONUDevice.id == onu_id).update({"model": onu_info.model})
|
||||
|
||||
self.db.add(DeviceStatusHistory(
|
||||
onu_device_id=onu_id,
|
||||
status=status,
|
||||
@@ -88,32 +92,30 @@ class CheckService:
|
||||
raise Exception("关联的 OLT 不存在")
|
||||
|
||||
mac = device.mac_address.lower()
|
||||
base_cmd = olt.slot_command # e.g. "display onu slot"
|
||||
if device.slot_number is not None:
|
||||
cmd = f"{base_cmd} {device.slot_number} | include {mac}"
|
||||
else:
|
||||
cmd = f"{base_cmd} | include {mac}"
|
||||
cmd = f"{olt.slot_command} | include {mac}"
|
||||
|
||||
ssh = SSHService(olt.ip_address, olt.username, olt.password)
|
||||
try:
|
||||
ssh.connect()
|
||||
output = ssh.execute_command(cmd)
|
||||
|
||||
# 从输出中找到包含该 MAC 的行并解析
|
||||
# 跳过命令回显行(含 MAC 但无端口标识),只接受有 port_id 的行
|
||||
info = None
|
||||
for line in output.splitlines():
|
||||
if mac in line.lower():
|
||||
info = ssh._parse_device_line(line, device.slot_number)
|
||||
if info:
|
||||
parsed = ssh._parse_device_line(line, device.slot_number)
|
||||
if parsed and parsed.port_id:
|
||||
info = parsed
|
||||
break
|
||||
|
||||
if info is None:
|
||||
# 未找到该 MAC,视为离线
|
||||
status = 'offline'
|
||||
distance_m = None
|
||||
else:
|
||||
status = info.status
|
||||
distance_m = parse_distance(info.distance_str)
|
||||
if info.model and not device.model:
|
||||
device.model = info.model
|
||||
|
||||
self.db.add(DeviceStatusHistory(
|
||||
onu_device_id=device.id,
|
||||
@@ -123,7 +125,7 @@ class CheckService:
|
||||
response_data=None,
|
||||
))
|
||||
self.db.commit()
|
||||
return {"status": status, "distance_m": distance_m}
|
||||
return {"status": status, "distance_m": distance_m, "model": device.model}
|
||||
finally:
|
||||
ssh.close()
|
||||
|
||||
@@ -147,14 +149,14 @@ class CheckService:
|
||||
new_count = 0
|
||||
|
||||
existing = {
|
||||
row.mac_address.lower(): row.id
|
||||
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id).all()
|
||||
row.mac_address.lower(): (row.id, row.model)
|
||||
for row in self.db.query(ONUDevice.mac_address, ONUDevice.id, ONUDevice.model).all()
|
||||
}
|
||||
|
||||
for mac, onu_info in onu_info_dict.items():
|
||||
onu_id = existing.get(mac)
|
||||
entry = existing.get(mac)
|
||||
|
||||
if onu_id is None:
|
||||
if entry is None:
|
||||
# 新设备:只入库在线的
|
||||
if onu_info.status != 'online':
|
||||
continue
|
||||
@@ -171,8 +173,12 @@ class CheckService:
|
||||
self.db.add(onu)
|
||||
self.db.flush()
|
||||
self.db.add(NewDevice(onu_device_id=onu.id, olt_id=olt_id))
|
||||
onu_id = onu.id
|
||||
new_count += 1
|
||||
onu_id = onu.id
|
||||
else:
|
||||
onu_id, onu_model = entry
|
||||
if onu_info.model and not onu_model:
|
||||
self.db.query(ONUDevice).filter(ONUDevice.id == onu_id).update({"model": onu_info.model})
|
||||
|
||||
status = onu_info.status
|
||||
distance_m = parse_distance(onu_info.distance_str)
|
||||
|
||||
@@ -276,6 +276,66 @@ class SSHService:
|
||||
model=model
|
||||
)
|
||||
|
||||
def get_olt_ports(self) -> list:
|
||||
"""获取所有 Olt 端口状态,返回 [{'name': 'Olt1/0/1', 'status': 'up'}, ...]"""
|
||||
output = self.execute_command("display interface brief")
|
||||
ports = []
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("Olt"):
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
name = parts[0]
|
||||
link = parts[1].upper()
|
||||
# ADM 表示 administratively down(手动关闭)
|
||||
if link == "ADM":
|
||||
status = "adm-down"
|
||||
elif link == "UP":
|
||||
status = "up"
|
||||
else:
|
||||
status = "down"
|
||||
ports.append({"name": name, "status": status})
|
||||
return ports
|
||||
|
||||
def toggle_olt_port(self, port_name: str, action: str) -> bool:
|
||||
"""开启或关闭 OLT 端口
|
||||
action: 'shutdown' 或 'undo shutdown'
|
||||
流程: system-view -> interface {port_name} -> shutdown/undo shutdown -> quit -> quit
|
||||
"""
|
||||
if not self.shell:
|
||||
raise Exception("SSH 未连接")
|
||||
|
||||
def send_and_wait(cmd: str, expect: str, timeout: int = 10) -> str:
|
||||
self.shell.send(cmd + "\n")
|
||||
buf = ""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if self.shell.recv_ready():
|
||||
buf += self.shell.recv(4096).decode('utf-8', errors='ignore')
|
||||
if expect in buf:
|
||||
return buf
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
return buf
|
||||
|
||||
out = send_and_wait("system-view", "]")
|
||||
if "]" not in out:
|
||||
raise Exception("进入 system-view 失败")
|
||||
|
||||
out = send_and_wait(f"interface {port_name}", "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"进入端口 {port_name} 失败")
|
||||
|
||||
out = send_and_wait(action, "]")
|
||||
if "]" not in out:
|
||||
raise Exception(f"执行 {action} 失败")
|
||||
|
||||
send_and_wait("quit", "]", timeout=5)
|
||||
send_and_wait("quit", ">", timeout=5)
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
"""关闭 SSH 连接"""
|
||||
if self.client:
|
||||
|
||||
@@ -1,34 +1,176 @@
|
||||
<template>
|
||||
<el-container>
|
||||
<el-header>
|
||||
<div class="header-content">
|
||||
<h3>H3C ONU 设备管理系统</h3>
|
||||
<el-button @click="handleLogout">退出登录</el-button>
|
||||
<div class="app-shell">
|
||||
<!-- 侧边栏 -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="2" y="2" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.9"/>
|
||||
<rect x="14" y="2" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="2" y="14" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="14" y="14" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<span class="brand-title">H3C ONU</span>
|
||||
<span class="brand-sub">设备管理系统</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-container>
|
||||
<el-aside width="200px">
|
||||
<el-menu router>
|
||||
<el-menu-item index="/dashboard">仪表板</el-menu-item>
|
||||
<el-menu-item index="/devices">设备列表</el-menu-item>
|
||||
<el-menu-item index="/import">数据导入</el-menu-item>
|
||||
<el-menu-item index="/charts">统计图表</el-menu-item>
|
||||
<el-menu-item index="/olt">OLT管理</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main>
|
||||
|
||||
<div class="sidebar-status">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">系统运行中</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<router-link
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
>
|
||||
<span class="nav-icon" v-html="item.icon"></span>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
<span class="nav-indicator"></span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button class="logout-btn" @click="handleLogout">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
<span>退出登录</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="main-area">
|
||||
<!-- 顶部栏 -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="breadcrumb">
|
||||
<span class="breadcrumb-root">控制台</span>
|
||||
<span class="breadcrumb-sep">/</span>
|
||||
<span class="breadcrumb-current">{{ currentPageName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="topbar-time">{{ currentTime }}</div>
|
||||
<!-- 主题切换 -->
|
||||
<button class="theme-toggle" @click="themeStore.toggle()" :title="themeStore.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'">
|
||||
<!-- 深色时显示「太阳」图标,点击切换到浅色 -->
|
||||
<svg v-if="themeStore.theme === 'dark'" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
<!-- 浅色时显示「月亮」图标,点击切换到深色 -->
|
||||
<svg v-else width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
<span class="theme-label">{{ themeStore.theme === 'dark' ? '浅色' : '深色' }}</span>
|
||||
</button>
|
||||
<div class="topbar-badge">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
v0.5.0
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<main class="page-content">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useThemeStore } from '../stores/theme'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const currentTime = ref('')
|
||||
|
||||
const updateTime = () => {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}
|
||||
let timer = null
|
||||
onMounted(() => { updateTime(); timer = setInterval(updateTime, 1000) })
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
label: '统计概览',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</svg>`
|
||||
},
|
||||
{
|
||||
path: '/devices',
|
||||
label: '设备列表',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
|
||||
<line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/>
|
||||
</svg>`
|
||||
},
|
||||
{
|
||||
path: '/olt',
|
||||
label: 'OLT 管理',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<circle cx="7" cy="9" r="1.2" fill="currentColor"/><circle cx="11" cy="9" r="1.2" fill="currentColor"/>
|
||||
<circle cx="7" cy="15" r="1.2" fill="currentColor"/><circle cx="11" cy="15" r="1.2" fill="currentColor"/>
|
||||
<line x1="15" y1="7" x2="20" y2="7"/><line x1="15" y1="12" x2="20" y2="12"/><line x1="15" y1="17" x2="20" y2="17"/>
|
||||
</svg>`
|
||||
},
|
||||
{
|
||||
path: '/charts',
|
||||
label: '统计图表',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/><line x1="2" y1="20" x2="22" y2="20"/>
|
||||
</svg>`
|
||||
},
|
||||
{
|
||||
path: '/import',
|
||||
label: '数据导入',
|
||||
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>`
|
||||
},
|
||||
]
|
||||
|
||||
const isActive = (path) => route.path === path || route.path.startsWith(path + '/')
|
||||
|
||||
const pageNameMap = {
|
||||
'/dashboard': '统计概览',
|
||||
'/devices': '设备列表',
|
||||
'/olt': 'OLT 管理',
|
||||
'/charts': '统计图表',
|
||||
'/import': '数据导入',
|
||||
}
|
||||
const currentPageName = computed(() => pageNameMap[route.path] || '页面')
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
@@ -37,21 +179,283 @@ const handleLogout = () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-header {
|
||||
background: #545c64;
|
||||
color: #fff;
|
||||
.app-shell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
/* ── 侧边栏 ── */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
padding: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 品牌 */
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 22px 20px 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--border-accent);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* 状态指示 */
|
||||
.sidebar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 6px var(--success);
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* 导航 */
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 12px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.18s ease;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border-accent);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-icon { opacity: 1; }
|
||||
|
||||
.nav-label { flex: 1; }
|
||||
|
||||
.nav-indicator {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-indicator { opacity: 1; }
|
||||
|
||||
/* 底部 */
|
||||
.sidebar-footer {
|
||||
padding: 14px 10px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: all 0.18s;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
.logout-btn:hover {
|
||||
background: var(--danger-dim);
|
||||
border-color: rgba(239,68,68,0.25);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ── 主区域 ── */
|
||||
.main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 顶部栏 */
|
||||
.topbar {
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.topbar-left, .topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.breadcrumb-root {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.breadcrumb-sep {
|
||||
color: var(--border-strong);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.topbar-time {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.topbar-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 页面内容 */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 主题切换按钮 */
|
||||
.theme-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.theme-label {
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import './styles/theme.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const STORAGE_KEY = 'onu-theme'
|
||||
const theme = ref(localStorage.getItem(STORAGE_KEY) || 'dark')
|
||||
|
||||
const applyTheme = (t) => {
|
||||
document.documentElement.setAttribute('data-theme', t)
|
||||
}
|
||||
|
||||
// 初始化时立即应用
|
||||
applyTheme(theme.value)
|
||||
|
||||
// 切换
|
||||
const toggle = () => {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
// 监听变化:同步到 DOM + localStorage
|
||||
watch(theme, (t) => {
|
||||
applyTheme(t)
|
||||
localStorage.setItem(STORAGE_KEY, t)
|
||||
})
|
||||
|
||||
return { theme, toggle }
|
||||
})
|
||||
@@ -0,0 +1,732 @@
|
||||
/* ============================================================
|
||||
H3C ONU 管理系统 — 全局主题
|
||||
工业深色风格 · 深海蓝黑 + 青绿主调
|
||||
============================================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
/* ── 基础色板 ── */
|
||||
--bg-base: #0a0e1a;
|
||||
--bg-surface: #0f1525;
|
||||
--bg-elevated: #141c30;
|
||||
--bg-card: #18213a;
|
||||
--bg-hover: #1e2a42;
|
||||
--bg-active: #1a3050;
|
||||
|
||||
/* ── 边框 ── */
|
||||
--border-subtle: rgba(255,255,255,0.05);
|
||||
--border-default: rgba(255,255,255,0.10);
|
||||
--border-strong: rgba(255,255,255,0.18);
|
||||
--border-accent: rgba(0,210,180,0.35);
|
||||
|
||||
/* ── 主色调:青绿 ── */
|
||||
--accent: #00d2b4;
|
||||
--accent-dim: rgba(0,210,180,0.15);
|
||||
--accent-glow: rgba(0,210,180,0.30);
|
||||
--accent-dark: #009e88;
|
||||
|
||||
/* ── 状态色 ── */
|
||||
--success: #22c55e;
|
||||
--success-dim: rgba(34,197,94,0.15);
|
||||
--danger: #ef4444;
|
||||
--danger-dim: rgba(239,68,68,0.15);
|
||||
--warning: #f59e0b;
|
||||
--warning-dim: rgba(245,158,11,0.15);
|
||||
--info: #3b82f6;
|
||||
--info-dim: rgba(59,130,246,0.15);
|
||||
|
||||
/* ── 文字 ── */
|
||||
--text-primary: #e8edf5;
|
||||
--text-secondary: #8a9ab8;
|
||||
--text-muted: #4a5a78;
|
||||
--text-accent: #00d2b4;
|
||||
|
||||
/* ── 字体 ── */
|
||||
--font-sans: 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Consolas', monospace;
|
||||
|
||||
/* ── 圆角 ── */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
/* ── 阴影 ── */
|
||||
--shadow-card: 0 4px 24px rgba(0,0,0,0.4), 0 1px 4px rgba(0,0,0,0.3);
|
||||
--shadow-glow: 0 0 20px rgba(0,210,180,0.15);
|
||||
}
|
||||
|
||||
/* ── 全局重置 ── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── Element Plus 深色覆盖 ── */
|
||||
|
||||
/* 容器 */
|
||||
.el-container { background: var(--bg-base) !important; }
|
||||
.el-main { background: var(--bg-base) !important; padding: 24px !important; }
|
||||
|
||||
/* 卡片 */
|
||||
.el-card {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-lg) !important;
|
||||
box-shadow: var(--shadow-card) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.el-card__header {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
padding: 14px 20px !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.el-card__body { color: var(--text-primary) !important; }
|
||||
|
||||
/* 表格 */
|
||||
.el-table {
|
||||
background: transparent !important;
|
||||
color: var(--text-primary) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
overflow: hidden !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
}
|
||||
.el-table th.el-table__cell {
|
||||
background: var(--bg-elevated) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.05em !important;
|
||||
text-transform: uppercase !important;
|
||||
border-bottom: 1px solid var(--border-default) !important;
|
||||
}
|
||||
.el-table td.el-table__cell {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.el-table tr { background: transparent !important; }
|
||||
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
|
||||
background: rgba(255,255,255,0.02) !important;
|
||||
}
|
||||
.el-table__body tr:hover > td.el-table__cell {
|
||||
background: var(--bg-hover) !important;
|
||||
}
|
||||
.el-table__empty-block { background: transparent !important; }
|
||||
.el-table__empty-text { color: var(--text-muted) !important; }
|
||||
.el-table__inner-wrapper::before { background: var(--border-default) !important; }
|
||||
|
||||
/* 分页 */
|
||||
.el-pagination {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
.el-pagination .el-pager li {
|
||||
background: var(--bg-elevated) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
margin: 0 2px !important;
|
||||
}
|
||||
.el-pagination .el-pager li.is-active {
|
||||
background: var(--accent-dim) !important;
|
||||
color: var(--accent) !important;
|
||||
border-color: var(--border-accent) !important;
|
||||
}
|
||||
.el-pagination .el-pager li:hover {
|
||||
color: var(--accent) !important;
|
||||
}
|
||||
.el-pagination button {
|
||||
background: var(--bg-elevated) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
}
|
||||
.el-pagination button:hover { color: var(--accent) !important; }
|
||||
.el-pagination .el-select .el-input__wrapper {
|
||||
background: var(--bg-elevated) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
}
|
||||
.el-pagination .el-select .el-input__inner { color: var(--text-secondary) !important; }
|
||||
|
||||
/* 输入框 */
|
||||
.el-input__wrapper {
|
||||
background: var(--bg-elevated) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
box-shadow: none !important;
|
||||
transition: border-color 0.2s !important;
|
||||
}
|
||||
.el-input__wrapper:hover { border-color: var(--border-strong) !important; }
|
||||
.el-input__wrapper.is-focus {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px var(--accent-dim) !important;
|
||||
}
|
||||
.el-input__inner {
|
||||
color: var(--text-primary) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
.el-input__inner::placeholder { color: var(--text-muted) !important; }
|
||||
.el-textarea__inner {
|
||||
background: var(--bg-elevated) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.el-textarea__inner:focus {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px var(--accent-dim) !important;
|
||||
}
|
||||
|
||||
/* Select 下拉 */
|
||||
.el-select-dropdown {
|
||||
background: var(--bg-elevated) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
box-shadow: var(--shadow-card) !important;
|
||||
}
|
||||
.el-select-dropdown__item {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
.el-select-dropdown__item:hover,
|
||||
.el-select-dropdown__item.hover {
|
||||
background: var(--bg-hover) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.el-select-dropdown__item.selected {
|
||||
color: var(--accent) !important;
|
||||
background: var(--accent-dim) !important;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.el-button {
|
||||
border-radius: var(--radius-sm) !important;
|
||||
font-family: var(--font-sans) !important;
|
||||
font-weight: 500 !important;
|
||||
transition: all 0.2s !important;
|
||||
}
|
||||
.el-button--default {
|
||||
background: var(--bg-elevated) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
.el-button--default:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
border-color: var(--border-strong) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.el-button--primary {
|
||||
background: var(--accent) !important;
|
||||
border-color: var(--accent) !important;
|
||||
color: #0a0e1a !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
.el-button--primary:hover {
|
||||
background: #00f0cc !important;
|
||||
border-color: #00f0cc !important;
|
||||
box-shadow: 0 0 12px var(--accent-glow) !important;
|
||||
}
|
||||
.el-button--success {
|
||||
background: var(--success) !important;
|
||||
border-color: var(--success) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.el-button--danger {
|
||||
background: var(--danger) !important;
|
||||
border-color: var(--danger) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.el-button--warning {
|
||||
background: var(--warning) !important;
|
||||
border-color: var(--warning) !important;
|
||||
color: #0a0e1a !important;
|
||||
}
|
||||
.el-button--info {
|
||||
background: var(--info) !important;
|
||||
border-color: var(--info) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.el-button.is-plain.el-button--danger {
|
||||
background: var(--danger-dim) !important;
|
||||
border-color: var(--danger) !important;
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
.el-button.is-plain.el-button--warning {
|
||||
background: var(--warning-dim) !important;
|
||||
border-color: var(--warning) !important;
|
||||
color: var(--warning) !important;
|
||||
}
|
||||
.el-button.is-plain.el-button--primary {
|
||||
background: var(--accent-dim) !important;
|
||||
border-color: var(--accent) !important;
|
||||
color: var(--accent) !important;
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
.el-tag {
|
||||
border-radius: var(--radius-sm) !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 600 !important;
|
||||
letter-spacing: 0.03em !important;
|
||||
}
|
||||
.el-tag--success {
|
||||
background: var(--success-dim) !important;
|
||||
border-color: rgba(34,197,94,0.3) !important;
|
||||
color: var(--success) !important;
|
||||
}
|
||||
.el-tag--danger {
|
||||
background: var(--danger-dim) !important;
|
||||
border-color: rgba(239,68,68,0.3) !important;
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
.el-tag--warning {
|
||||
background: var(--warning-dim) !important;
|
||||
border-color: rgba(245,158,11,0.3) !important;
|
||||
color: var(--warning) !important;
|
||||
}
|
||||
.el-tag--info {
|
||||
background: rgba(255,255,255,0.06) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
/* 对话框 */
|
||||
.el-dialog {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-lg) !important;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.6) !important;
|
||||
}
|
||||
.el-dialog__header {
|
||||
background: var(--bg-elevated) !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
padding: 16px 20px !important;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0 !important;
|
||||
}
|
||||
.el-dialog__title {
|
||||
color: var(--text-primary) !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
.el-dialog__headerbtn .el-dialog__close { color: var(--text-muted) !important; }
|
||||
.el-dialog__headerbtn:hover .el-dialog__close { color: var(--text-primary) !important; }
|
||||
.el-dialog__body { color: var(--text-primary) !important; padding: 20px !important; }
|
||||
.el-dialog__footer {
|
||||
border-top: 1px solid var(--border-subtle) !important;
|
||||
padding: 14px 20px !important;
|
||||
}
|
||||
.el-overlay { background: rgba(0,0,0,0.7) !important; }
|
||||
|
||||
/* 表单 */
|
||||
.el-form-item__label { color: var(--text-secondary) !important; }
|
||||
.el-form--inline .el-form-item { margin-bottom: 0 !important; }
|
||||
|
||||
/* 描述列表 */
|
||||
.el-descriptions__header { color: var(--text-primary) !important; }
|
||||
.el-descriptions__body { background: transparent !important; }
|
||||
.el-descriptions__body .el-descriptions__table { background: transparent !important; }
|
||||
.el-descriptions__cell { background: transparent !important; }
|
||||
.el-descriptions--bordered .el-descriptions__cell {
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
.el-descriptions--bordered .el-descriptions__label {
|
||||
background: var(--bg-elevated) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.el-progress-bar__outer {
|
||||
background: var(--bg-elevated) !important;
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
|
||||
/* 统计数字 */
|
||||
.el-statistic__head { color: var(--text-muted) !important; font-size: 12px !important; }
|
||||
.el-statistic__content { color: var(--text-primary) !important; }
|
||||
.el-statistic__number { color: var(--text-primary) !important; }
|
||||
|
||||
/* 警告框 */
|
||||
.el-alert {
|
||||
border-radius: var(--radius-md) !important;
|
||||
border: 1px solid !important;
|
||||
}
|
||||
.el-alert--success {
|
||||
background: var(--success-dim) !important;
|
||||
border-color: rgba(34,197,94,0.3) !important;
|
||||
}
|
||||
.el-alert--error {
|
||||
background: var(--danger-dim) !important;
|
||||
border-color: rgba(239,68,68,0.3) !important;
|
||||
}
|
||||
.el-alert--warning {
|
||||
background: var(--warning-dim) !important;
|
||||
border-color: rgba(245,158,11,0.3) !important;
|
||||
}
|
||||
.el-alert__title { color: var(--text-primary) !important; }
|
||||
.el-alert__description { color: var(--text-secondary) !important; }
|
||||
|
||||
/* 链接 */
|
||||
.el-link { font-weight: 500 !important; }
|
||||
.el-link--primary { color: var(--accent) !important; }
|
||||
.el-link--primary:hover { color: #00f0cc !important; }
|
||||
|
||||
/* 分割线 */
|
||||
.el-divider { border-color: var(--border-subtle) !important; }
|
||||
.el-divider__text {
|
||||
background: var(--bg-card) !important;
|
||||
color: var(--text-muted) !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
/* 消息提示 */
|
||||
.el-message {
|
||||
background: var(--bg-elevated) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
box-shadow: var(--shadow-card) !important;
|
||||
}
|
||||
.el-message__content { color: var(--text-primary) !important; }
|
||||
|
||||
/* 确认框 */
|
||||
.el-message-box {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
border-radius: var(--radius-lg) !important;
|
||||
}
|
||||
.el-message-box__header { background: var(--bg-elevated) !important; border-radius: var(--radius-lg) var(--radius-lg) 0 0 !important; }
|
||||
.el-message-box__title { color: var(--text-primary) !important; }
|
||||
.el-message-box__content { color: var(--text-secondary) !important; }
|
||||
|
||||
/* 滚动条 */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* 上传 */
|
||||
.el-upload { display: inline-block !important; }
|
||||
|
||||
/* 徽章 */
|
||||
.el-badge__content { font-size: 10px !important; }
|
||||
|
||||
|
||||
/* ============================================================
|
||||
浅色主题 — 精工白瓷 · 云白 + 深青绿主调
|
||||
[data-theme="light"] 挂载在 <html> 上,覆盖 :root 变量
|
||||
============================================================ */
|
||||
|
||||
[data-theme="light"] {
|
||||
/* ── 基础色板 ── */
|
||||
--bg-base: #f4f6f9;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-elevated: #f0f2f7;
|
||||
--bg-card: #ffffff;
|
||||
--bg-hover: #e8ecf4;
|
||||
--bg-active: #dde5f5;
|
||||
|
||||
/* ── 边框 ── */
|
||||
--border-subtle: rgba(0,0,0,0.06);
|
||||
--border-default: rgba(0,0,0,0.10);
|
||||
--border-strong: rgba(0,0,0,0.18);
|
||||
--border-accent: rgba(0,160,140,0.35);
|
||||
|
||||
/* ── 主色调:深青绿(比深色版略深,在白底上有足够对比) ── */
|
||||
--accent: #00a08c;
|
||||
--accent-dim: rgba(0,160,140,0.10);
|
||||
--accent-glow: rgba(0,160,140,0.25);
|
||||
--accent-dark: #007a6b;
|
||||
|
||||
/* ── 状态色(加深版,确保白底可读性) ── */
|
||||
--success: #16a34a;
|
||||
--success-dim: rgba(22,163,74,0.10);
|
||||
--danger: #dc2626;
|
||||
--danger-dim: rgba(220,38,38,0.08);
|
||||
--warning: #d97706;
|
||||
--warning-dim: rgba(217,119,6,0.10);
|
||||
--info: #2563eb;
|
||||
--info-dim: rgba(37,99,235,0.10);
|
||||
|
||||
/* ── 文字 ── */
|
||||
--text-primary: #111827;
|
||||
--text-secondary: #374151;
|
||||
--text-muted: #9ca3af;
|
||||
--text-accent: #00a08c;
|
||||
|
||||
/* ── 阴影(浅色版用更细腻的阴影) ── */
|
||||
--shadow-card: 0 1px 4px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.06);
|
||||
--shadow-glow: 0 0 16px rgba(0,160,140,0.12);
|
||||
}
|
||||
|
||||
/* ── 浅色主题下的全局背景 ── */
|
||||
[data-theme="light"] body,
|
||||
[data-theme="light"] #app {
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── 浅色主题下的 Element Plus 覆盖 ── */
|
||||
|
||||
/* 容器 */
|
||||
[data-theme="light"] .el-container { background: var(--bg-base) !important; }
|
||||
[data-theme="light"] .el-main { background: var(--bg-base) !important; }
|
||||
|
||||
/* 卡片 */
|
||||
[data-theme="light"] .el-card {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
box-shadow: var(--shadow-card) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-card__header {
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-card__body { color: var(--text-primary) !important; }
|
||||
|
||||
/* 表格 */
|
||||
[data-theme="light"] .el-table {
|
||||
background: var(--bg-card) !important;
|
||||
color: var(--text-primary) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
}
|
||||
[data-theme="light"] .el-table th.el-table__cell {
|
||||
background: var(--bg-elevated) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border-bottom: 1px solid var(--border-default) !important;
|
||||
}
|
||||
[data-theme="light"] .el-table td.el-table__cell {
|
||||
background: var(--bg-card) !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-table tr { background: var(--bg-card) !important; }
|
||||
[data-theme="light"] .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
|
||||
background: #f8fafc !important;
|
||||
}
|
||||
[data-theme="light"] .el-table__body tr:hover > td.el-table__cell {
|
||||
background: var(--bg-hover) !important;
|
||||
}
|
||||
[data-theme="light"] .el-table__empty-block { background: var(--bg-card) !important; }
|
||||
[data-theme="light"] .el-table__empty-text { color: var(--text-muted) !important; }
|
||||
[data-theme="light"] .el-table__inner-wrapper::before { background: var(--border-default) !important; }
|
||||
|
||||
/* 分页 */
|
||||
[data-theme="light"] .el-pagination { color: var(--text-secondary) !important; }
|
||||
[data-theme="light"] .el-pagination .el-pager li {
|
||||
background: var(--bg-card) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
}
|
||||
[data-theme="light"] .el-pagination .el-pager li.is-active {
|
||||
background: var(--accent-dim) !important;
|
||||
color: var(--accent) !important;
|
||||
border-color: var(--border-accent) !important;
|
||||
}
|
||||
[data-theme="light"] .el-pagination .el-pager li:hover { color: var(--accent) !important; }
|
||||
[data-theme="light"] .el-pagination button {
|
||||
background: var(--bg-card) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
}
|
||||
[data-theme="light"] .el-pagination button:hover { color: var(--accent) !important; }
|
||||
[data-theme="light"] .el-pagination .el-select .el-input__wrapper {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
}
|
||||
[data-theme="light"] .el-pagination .el-select .el-input__inner { color: var(--text-secondary) !important; }
|
||||
|
||||
/* 输入框 */
|
||||
[data-theme="light"] .el-input__wrapper {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
[data-theme="light"] .el-input__wrapper:hover { border-color: var(--border-strong) !important; }
|
||||
[data-theme="light"] .el-input__wrapper.is-focus {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px var(--accent-dim) !important;
|
||||
}
|
||||
[data-theme="light"] .el-input__inner {
|
||||
color: var(--text-primary) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
[data-theme="light"] .el-input__inner::placeholder { color: var(--text-muted) !important; }
|
||||
[data-theme="light"] .el-textarea__inner {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-textarea__inner:focus {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px var(--accent-dim) !important;
|
||||
}
|
||||
|
||||
/* Select 下拉 */
|
||||
[data-theme="light"] .el-select-dropdown {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.12) !important;
|
||||
}
|
||||
[data-theme="light"] .el-select-dropdown__item { color: var(--text-secondary) !important; }
|
||||
[data-theme="light"] .el-select-dropdown__item:hover,
|
||||
[data-theme="light"] .el-select-dropdown__item.hover {
|
||||
background: var(--bg-hover) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-select-dropdown__item.selected {
|
||||
color: var(--accent) !important;
|
||||
background: var(--accent-dim) !important;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
[data-theme="light"] .el-button--default {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-button--default:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
border-color: var(--border-strong) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-button--primary {
|
||||
background: var(--accent) !important;
|
||||
border-color: var(--accent) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
[data-theme="light"] .el-button--primary:hover {
|
||||
background: #00c4aa !important;
|
||||
border-color: #00c4aa !important;
|
||||
box-shadow: 0 0 10px var(--accent-glow) !important;
|
||||
}
|
||||
[data-theme="light"] .el-button.is-plain.el-button--danger {
|
||||
background: var(--danger-dim) !important;
|
||||
border-color: var(--danger) !important;
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
[data-theme="light"] .el-button.is-plain.el-button--warning {
|
||||
background: var(--warning-dim) !important;
|
||||
border-color: var(--warning) !important;
|
||||
color: var(--warning) !important;
|
||||
}
|
||||
[data-theme="light"] .el-button.is-plain.el-button--primary {
|
||||
background: var(--accent-dim) !important;
|
||||
border-color: var(--accent) !important;
|
||||
color: var(--accent) !important;
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
[data-theme="light"] .el-tag--info {
|
||||
background: rgba(0,0,0,0.04) !important;
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
/* 对话框 */
|
||||
[data-theme="light"] .el-dialog {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.15) !important;
|
||||
}
|
||||
[data-theme="light"] .el-dialog__header {
|
||||
background: var(--bg-elevated) !important;
|
||||
border-bottom: 1px solid var(--border-subtle) !important;
|
||||
}
|
||||
[data-theme="light"] .el-dialog__title { color: var(--text-primary) !important; }
|
||||
[data-theme="light"] .el-dialog__headerbtn .el-dialog__close { color: var(--text-muted) !important; }
|
||||
[data-theme="light"] .el-dialog__headerbtn:hover .el-dialog__close { color: var(--text-primary) !important; }
|
||||
[data-theme="light"] .el-dialog__body { color: var(--text-primary) !important; }
|
||||
[data-theme="light"] .el-dialog__footer { border-top: 1px solid var(--border-subtle) !important; }
|
||||
[data-theme="light"] .el-overlay { background: rgba(0,0,0,0.4) !important; }
|
||||
|
||||
/* 表单 */
|
||||
[data-theme="light"] .el-form-item__label { color: var(--text-secondary) !important; }
|
||||
|
||||
/* 描述列表 */
|
||||
[data-theme="light"] .el-descriptions__body { background: transparent !important; }
|
||||
[data-theme="light"] .el-descriptions__body .el-descriptions__table { background: transparent !important; }
|
||||
[data-theme="light"] .el-descriptions--bordered .el-descriptions__cell {
|
||||
border-color: var(--border-default) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
[data-theme="light"] .el-descriptions--bordered .el-descriptions__label {
|
||||
background: var(--bg-elevated) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
[data-theme="light"] .el-progress-bar__outer { background: var(--bg-elevated) !important; }
|
||||
|
||||
/* 统计数字 */
|
||||
[data-theme="light"] .el-statistic__head { color: var(--text-muted) !important; }
|
||||
[data-theme="light"] .el-statistic__content { color: var(--text-primary) !important; }
|
||||
[data-theme="light"] .el-statistic__number { color: var(--text-primary) !important; }
|
||||
|
||||
/* 警告框 */
|
||||
[data-theme="light"] .el-alert__title { color: var(--text-primary) !important; }
|
||||
[data-theme="light"] .el-alert__description { color: var(--text-secondary) !important; }
|
||||
[data-theme="light"] .el-alert--success {
|
||||
background: var(--success-dim) !important;
|
||||
border-color: rgba(22,163,74,0.25) !important;
|
||||
}
|
||||
[data-theme="light"] .el-alert--error {
|
||||
background: var(--danger-dim) !important;
|
||||
border-color: rgba(220,38,38,0.25) !important;
|
||||
}
|
||||
[data-theme="light"] .el-alert--warning {
|
||||
background: var(--warning-dim) !important;
|
||||
border-color: rgba(217,119,6,0.25) !important;
|
||||
}
|
||||
|
||||
/* 链接 */
|
||||
[data-theme="light"] .el-link--primary { color: var(--accent) !important; }
|
||||
[data-theme="light"] .el-link--primary:hover { color: var(--accent-dark) !important; }
|
||||
|
||||
/* 分割线 */
|
||||
[data-theme="light"] .el-divider { border-color: var(--border-subtle) !important; }
|
||||
[data-theme="light"] .el-divider__text {
|
||||
background: var(--bg-card) !important;
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* 消息提示 */
|
||||
[data-theme="light"] .el-message {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.12) !important;
|
||||
}
|
||||
[data-theme="light"] .el-message__content { color: var(--text-primary) !important; }
|
||||
|
||||
/* 确认框 */
|
||||
[data-theme="light"] .el-message-box {
|
||||
background: var(--bg-card) !important;
|
||||
border: 1px solid var(--border-default) !important;
|
||||
}
|
||||
[data-theme="light"] .el-message-box__header {
|
||||
background: var(--bg-elevated) !important;
|
||||
}
|
||||
[data-theme="light"] .el-message-box__title { color: var(--text-primary) !important; }
|
||||
[data-theme="light"] .el-message-box__content { color: var(--text-secondary) !important; }
|
||||
|
||||
/* 滚动条(浅色版) */
|
||||
[data-theme="light"] ::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); }
|
||||
[data-theme="light"] ::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); }
|
||||
|
||||
/* 主题过渡动画 */
|
||||
html {
|
||||
transition: background-color 0.25s ease, color 0.25s ease;
|
||||
}
|
||||
*, *::before, *::after {
|
||||
transition-property: background-color, border-color, color, box-shadow;
|
||||
transition-duration: 0.2s;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
+176
-30
@@ -1,14 +1,37 @@
|
||||
<template>
|
||||
<div class="charts-container">
|
||||
<el-card class="chart-card">
|
||||
<h3>设备状态趋势</h3>
|
||||
<div ref="trendChart" style="height: 300px"></div>
|
||||
</el-card>
|
||||
<div class="charts-page">
|
||||
<div class="page-header">
|
||||
<div class="page-title-group">
|
||||
<h1 class="page-title">统计图表</h1>
|
||||
<span class="page-subtitle">设备状态趋势与区域分布</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="chart-card">
|
||||
<h3>区域设备分布</h3>
|
||||
<div ref="regionChart" style="height: 300px"></div>
|
||||
</el-card>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 6px">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
7 天状态趋势
|
||||
</span>
|
||||
</div>
|
||||
<div ref="trendChart" class="chart-area"></div>
|
||||
</div>
|
||||
|
||||
<div class="chart-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -2px; margin-right: 6px">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 1 10 10"/>
|
||||
</svg>
|
||||
区域设备分布
|
||||
</span>
|
||||
</div>
|
||||
<div ref="regionChart" class="chart-area"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -20,34 +43,114 @@ import { getTrend, getByRegion } from '../api/stats'
|
||||
const trendChart = ref(null)
|
||||
const regionChart = ref(null)
|
||||
|
||||
const chartTheme = {
|
||||
backgroundColor: 'transparent',
|
||||
textStyle: { color: '#8a9ab8', fontFamily: 'Noto Sans SC, sans-serif', fontSize: 12 },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', top: '12%', containLabel: true },
|
||||
tooltip: {
|
||||
backgroundColor: '#141c30',
|
||||
borderColor: 'rgba(255,255,255,0.10)',
|
||||
textStyle: { color: '#e8edf5' },
|
||||
extraCssText: 'border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.4);'
|
||||
},
|
||||
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } },
|
||||
axisTick: { lineStyle: { color: 'rgba(255,255,255,0.08)' } },
|
||||
splitLine: { lineStyle: { color: 'rgba(255,255,255,0.05)', type: 'dashed' } },
|
||||
}
|
||||
|
||||
const initTrendChart = async () => {
|
||||
const { data } = await getTrend(7)
|
||||
const chart = echarts.init(trendChart.value)
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['在线', '离线'] },
|
||||
xAxis: { type: 'category', data: data.map(d => d.date) },
|
||||
yAxis: { type: 'value' },
|
||||
...chartTheme,
|
||||
tooltip: { ...chartTheme.tooltip, trigger: 'axis' },
|
||||
legend: {
|
||||
data: ['在线', '离线'],
|
||||
textStyle: { color: '#8a9ab8' },
|
||||
top: 4,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.map(d => d.date),
|
||||
axisLine: chartTheme.axisLine,
|
||||
axisTick: chartTheme.axisTick,
|
||||
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#8a9ab8', fontSize: 11 },
|
||||
splitLine: chartTheme.splitLine,
|
||||
},
|
||||
series: [
|
||||
{ name: '在线', type: 'line', data: data.map(d => d.online), smooth: true },
|
||||
{ name: '离线', type: 'line', data: data.map(d => d.offline), smooth: true }
|
||||
]
|
||||
{
|
||||
name: '在线',
|
||||
type: 'line',
|
||||
data: data.map(d => d.online),
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
lineStyle: { color: '#00d2b4', width: 2 },
|
||||
itemStyle: { color: '#00d2b4' },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(0,210,180,0.20)' },
|
||||
{ offset: 1, color: 'rgba(0,210,180,0.00)' },
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '离线',
|
||||
type: 'line',
|
||||
data: data.map(d => d.offline),
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 5,
|
||||
lineStyle: { color: '#ef4444', width: 2 },
|
||||
itemStyle: { color: '#ef4444' },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(239,68,68,0.15)' },
|
||||
{ offset: 1, color: 'rgba(239,68,68,0.00)' },
|
||||
])
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const initRegionChart = async () => {
|
||||
const { data } = await getByRegion()
|
||||
const chart = echarts.init(regionChart.value)
|
||||
|
||||
const colors = ['#00d2b4', '#3b82f6', '#a855f7', '#f59e0b', '#22c55e', '#ef4444', '#ec4899']
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'item' },
|
||||
legend: { orient: 'vertical', left: 'left' },
|
||||
...chartTheme,
|
||||
tooltip: { ...chartTheme.tooltip, trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
right: '5%',
|
||||
top: 'middle',
|
||||
textStyle: { color: '#8a9ab8', fontSize: 12 },
|
||||
itemWidth: 12,
|
||||
itemHeight: 12,
|
||||
},
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: '50%',
|
||||
data: data.map(d => ({ value: d.total, name: d.region }))
|
||||
}]
|
||||
radius: ['40%', '68%'],
|
||||
center: ['38%', '52%'],
|
||||
data: data.map((d, i) => ({
|
||||
value: d.total,
|
||||
name: d.region,
|
||||
itemStyle: { color: colors[i % colors.length] },
|
||||
})),
|
||||
label: { show: false },
|
||||
labelLine: { show: false },
|
||||
emphasis: {
|
||||
itemStyle: { shadowBlur: 16, shadowColor: 'rgba(0,0,0,0.4)' },
|
||||
scaleSize: 6,
|
||||
},
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,17 +161,60 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.charts-container {
|
||||
.charts-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
||||
gap: 20px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(480px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 20px;
|
||||
.chart-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 20px;
|
||||
.panel-header {
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
height: 320px;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+481
-202
@@ -1,102 +1,190 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<span class="page-title">统计信息</span>
|
||||
<el-button size="small" :loading="loading" @click="loadData">刷新</el-button>
|
||||
<div class="page-title-group">
|
||||
<h1 class="page-title">统计概览</h1>
|
||||
<span class="page-subtitle">实时监控 ONU 设备在线状态</span>
|
||||
</div>
|
||||
<button class="refresh-btn" :class="{ loading }" @click="loadData">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ spinning: loading }">
|
||||
<polyline points="23 4 23 10 17 10"/>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
{{ loading ? '刷新中…' : '刷新数据' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 顶部汇总卡片 -->
|
||||
<el-row :gutter="16" style="margin-bottom: 24px">
|
||||
<el-col :span="6" v-for="card in summaryCards" :key="card.key">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">{{ card.label }}</div>
|
||||
<div class="stat-rate" :style="{ color: rateColor(card.rate) }">
|
||||
{{ card.total > 0 ? card.rate.toFixed(1) + '%' : '-' }}
|
||||
<!-- 汇总卡片 -->
|
||||
<div class="stat-grid">
|
||||
<div
|
||||
v-for="(card, i) in summaryCards"
|
||||
:key="card.key"
|
||||
class="stat-card"
|
||||
:class="card.key"
|
||||
:style="{ animationDelay: i * 80 + 'ms' }"
|
||||
>
|
||||
<div class="stat-card-header">
|
||||
<span class="stat-label">{{ card.label }}</span>
|
||||
<div class="stat-icon" :style="{ color: rateColor(card.rate) }">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="dot online" />
|
||||
<span class="count-text">在线 {{ card.online }}</span>
|
||||
<span class="dot offline" style="margin-left: 12px" />
|
||||
<span class="count-text">离线 {{ card.offline }}</span>
|
||||
</div>
|
||||
<div class="stat-total">共 {{ card.total }} 台</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="stat-rate" :style="{ color: rateColor(card.rate) }">
|
||||
{{ card.total > 0 ? card.rate.toFixed(1) + '%' : '—' }}
|
||||
</div>
|
||||
<div class="stat-meta">
|
||||
<span class="meta-item online">
|
||||
<span class="led online"></span>{{ card.online }} 在线
|
||||
</span>
|
||||
<span class="meta-sep">/</span>
|
||||
<span class="meta-item offline">
|
||||
<span class="led offline"></span>{{ card.offline }} 离线
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-bar-wrap">
|
||||
<div
|
||||
class="stat-bar-fill"
|
||||
:style="{ width: card.total > 0 ? card.rate + '%' : '0%', background: rateColor(card.rate) }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="stat-total">共 {{ card.total }} 台设备</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细列表 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-card class="list-card">
|
||||
<template #header><span class="card-title">城区学校在线率</span></template>
|
||||
<div class="school-list">
|
||||
<template v-if="data.urban?.schools?.length">
|
||||
<div v-for="s in data.urban.schools" :key="s.name" class="school-item">
|
||||
<div class="school-header">
|
||||
<span class="school-name school-link" @click="goToSchool(s.name)">{{ s.name }}</span>
|
||||
<span class="school-rate" :style="{ color: rateColor(rate(s)) }">{{ rate(s).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<el-progress :percentage="rate(s)" :stroke-width="5" :show-text="false" :color="rateColor(rate(s))" />
|
||||
<div class="school-sub">在线 {{ s.online }} / 共 {{ s.total }}</div>
|
||||
<!-- 学校列表 -->
|
||||
<div class="list-grid">
|
||||
<!-- 城区 -->
|
||||
<div class="list-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title-group">
|
||||
<span class="panel-dot urban"></span>
|
||||
<span class="panel-title">城区学校</span>
|
||||
</div>
|
||||
<span class="panel-count">{{ data.urban?.schools?.length || 0 }} 所</span>
|
||||
</div>
|
||||
<div class="school-list">
|
||||
<template v-if="data.urban?.schools?.length">
|
||||
<div
|
||||
v-for="s in data.urban.schools"
|
||||
:key="s.name"
|
||||
class="school-row"
|
||||
@click="goToSchool(s.name)"
|
||||
>
|
||||
<div class="school-info">
|
||||
<span class="school-name">{{ s.name }}</span>
|
||||
<span class="school-stat">{{ s.online }}/{{ s.total }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-text">暂无数据</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card class="list-card">
|
||||
<template #header><span class="card-title">城郊学校在线率</span></template>
|
||||
<div class="school-list">
|
||||
<template v-if="data.suburban?.schools?.length">
|
||||
<div v-for="s in data.suburban.schools" :key="s.name" class="school-item">
|
||||
<div class="school-header">
|
||||
<span class="school-name school-link" @click="goToSchool(s.name)">{{ s.name }}</span>
|
||||
<span class="school-rate" :style="{ color: rateColor(rate(s)) }">{{ rate(s).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<el-progress :percentage="rate(s)" :stroke-width="5" :show-text="false" :color="rateColor(rate(s))" />
|
||||
<div class="school-sub">在线 {{ s.online }} / 共 {{ s.total }}</div>
|
||||
<div class="school-bar-wrap">
|
||||
<div
|
||||
class="school-bar-fill"
|
||||
:style="{ width: rate(s) + '%', background: rateColor(rate(s)) }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-text">暂无数据</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<span class="school-pct" :style="{ color: rateColor(rate(s)) }">
|
||||
{{ rate(s).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-hint">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card class="list-card">
|
||||
<template #header><span class="card-title">乡镇在线率</span></template>
|
||||
<div class="school-list">
|
||||
<template v-if="data.rural?.towns?.length">
|
||||
<div v-for="t in data.rural.towns" :key="t.region" class="school-item town-item" @click="openTown(t)">
|
||||
<div class="school-header">
|
||||
<span class="school-name town-name">{{ t.region }}</span>
|
||||
<span class="school-rate" :style="{ color: rateColor(rate(t)) }">
|
||||
{{ rate(t).toFixed(1) }}%
|
||||
<el-icon style="vertical-align: -2px; margin-left: 2px"><ArrowRight /></el-icon>
|
||||
</span>
|
||||
</div>
|
||||
<el-progress :percentage="rate(t)" :stroke-width="5" :show-text="false" :color="rateColor(rate(t))" />
|
||||
<div class="school-sub">在线 {{ t.online }} / 共 {{ t.total }}</div>
|
||||
<!-- 城郊 -->
|
||||
<div class="list-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title-group">
|
||||
<span class="panel-dot suburban"></span>
|
||||
<span class="panel-title">城郊学校</span>
|
||||
</div>
|
||||
<span class="panel-count">{{ data.suburban?.schools?.length || 0 }} 所</span>
|
||||
</div>
|
||||
<div class="school-list">
|
||||
<template v-if="data.suburban?.schools?.length">
|
||||
<div
|
||||
v-for="s in data.suburban.schools"
|
||||
:key="s.name"
|
||||
class="school-row"
|
||||
@click="goToSchool(s.name)"
|
||||
>
|
||||
<div class="school-info">
|
||||
<span class="school-name">{{ s.name }}</span>
|
||||
<span class="school-stat">{{ s.online }}/{{ s.total }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-text">暂无数据</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="school-bar-wrap">
|
||||
<div
|
||||
class="school-bar-fill"
|
||||
:style="{ width: rate(s) + '%', background: rateColor(rate(s)) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="school-pct" :style="{ color: rateColor(rate(s)) }">
|
||||
{{ rate(s).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-hint">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 乡镇学校详情 -->
|
||||
<el-dialog v-model="townVisible" :title="selectedTown?.region + ' — 学校在线率'" width="480px" destroy-on-close>
|
||||
<div class="school-list" style="max-height: 500px; overflow-y: auto; padding-right: 4px">
|
||||
<div v-for="s in selectedTown?.schools" :key="s.name" class="school-item">
|
||||
<div class="school-header">
|
||||
<span class="school-name school-link" @click="goToSchool(s.name)">{{ s.name }}</span>
|
||||
<span class="school-rate" :style="{ color: rateColor(rate(s)) }">{{ rate(s).toFixed(1) }}%</span>
|
||||
<!-- 乡镇 -->
|
||||
<div class="list-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title-group">
|
||||
<span class="panel-dot rural"></span>
|
||||
<span class="panel-title">乡镇地区</span>
|
||||
</div>
|
||||
<el-progress :percentage="rate(s)" :stroke-width="5" :show-text="false" :color="rateColor(rate(s))" />
|
||||
<div class="school-sub">在线 {{ s.online }} / 共 {{ s.total }}</div>
|
||||
<span class="panel-count">{{ data.rural?.towns?.length || 0 }} 个</span>
|
||||
</div>
|
||||
<div class="school-list">
|
||||
<template v-if="data.rural?.towns?.length">
|
||||
<div
|
||||
v-for="t in data.rural.towns"
|
||||
:key="t.region"
|
||||
class="school-row town-row"
|
||||
@click="openTown(t)"
|
||||
>
|
||||
<div class="school-info">
|
||||
<span class="school-name">{{ t.region }}</span>
|
||||
<span class="school-stat">{{ t.online }}/{{ t.total }}</span>
|
||||
</div>
|
||||
<div class="school-bar-wrap">
|
||||
<div
|
||||
class="school-bar-fill"
|
||||
:style="{ width: rate(t) + '%', background: rateColor(rate(t)) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="school-pct" :style="{ color: rateColor(rate(t)) }">
|
||||
{{ rate(t).toFixed(1) }}%
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="vertical-align: -1px; margin-left: 2px">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-hint">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 乡镇详情对话框 -->
|
||||
<el-dialog
|
||||
v-model="townVisible"
|
||||
:title="selectedTown?.region + ' · 学校在线率'"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="school-list dialog-list">
|
||||
<div v-for="s in selectedTown?.schools" :key="s.name" class="school-row" @click="goToSchool(s.name)">
|
||||
<div class="school-info">
|
||||
<span class="school-name">{{ s.name }}</span>
|
||||
<span class="school-stat">{{ s.online }}/{{ s.total }}</span>
|
||||
</div>
|
||||
<div class="school-bar-wrap">
|
||||
<div class="school-bar-fill" :style="{ width: rate(s) + '%', background: rateColor(rate(s)) }"></div>
|
||||
</div>
|
||||
<span class="school-pct" :style="{ color: rateColor(rate(s)) }">{{ rate(s).toFixed(1) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -108,12 +196,10 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ArrowRight } from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import request from '../utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref({})
|
||||
const townVisible = ref(false)
|
||||
@@ -125,9 +211,9 @@ const rate = (item) => {
|
||||
}
|
||||
|
||||
const rateColor = (r) => {
|
||||
if (r >= 90) return '#67c23a'
|
||||
if (r >= 70) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
if (r >= 90) return '#22c55e'
|
||||
if (r >= 70) return '#f59e0b'
|
||||
return '#ef4444'
|
||||
}
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
@@ -162,152 +248,345 @@ onMounted(loadData)
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ── 页头 ── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-rate {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.dot.online { background: #67c23a; }
|
||||
.dot.offline { background: #f56c6c; }
|
||||
|
||||
.count-text {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.school-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.school-item {
|
||||
.page-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.town-item {
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.town-item:hover {
|
||||
background: #f5f7fa;
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.school-header {
|
||||
.page-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.refresh-btn.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* ── 汇总卡片 ── */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
animation: card-in 0.35s ease both;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.02) 0%, transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--border-accent);
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
@keyframes card-in {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.stat-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.stat-rate {
|
||||
font-size: 38px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-bottom: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.stat-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta-sep {
|
||||
color: var(--border-strong);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.led {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.led.online {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 5px var(--success);
|
||||
}
|
||||
|
||||
.led.offline {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.stat-bar-wrap {
|
||||
height: 3px;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ── 学校列表 ── */
|
||||
.list-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.panel-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-dot.urban { background: #3b82f6; box-shadow: 0 0 6px #3b82f6; }
|
||||
.panel-dot.suburban { background: #a855f7; box-shadow: 0 0 6px #a855f7; }
|
||||
.panel-dot.rural { background: #f59e0b; box-shadow: 0 0 6px #f59e0b; }
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.panel-count {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-hover);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.school-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
max-height: 480px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dialog-list {
|
||||
max-height: 450px;
|
||||
}
|
||||
|
||||
.school-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 64px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.school-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.town-row {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.town-row:hover {
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.school-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.school-name {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.school-row:hover .school-name {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.school-stat {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.school-link {
|
||||
cursor: pointer;
|
||||
color: #409eff;
|
||||
}
|
||||
.school-link:hover {
|
||||
text-decoration: underline;
|
||||
.school-bar-wrap {
|
||||
height: 3px;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.town-name {
|
||||
font-weight: 500;
|
||||
.school-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.school-rate {
|
||||
font-size: 13px;
|
||||
.school-pct {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.school-sub {
|
||||
font-size: 11px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
color: #c0c4cc;
|
||||
font-size: 13px;
|
||||
padding: 40px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
+456
-155
@@ -1,90 +1,155 @@
|
||||
<template>
|
||||
<div class="device-list">
|
||||
<el-card>
|
||||
<el-form inline>
|
||||
<el-form-item label="区域">
|
||||
<el-select v-model="filters.region" placeholder="全部区域" clearable @change="search" style="width: 180px">
|
||||
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.status" placeholder="全部" clearable @change="search" style="width: 100px">
|
||||
<el-option label="在线" value="online" />
|
||||
<el-option label="离线" value="offline" />
|
||||
<el-option label="未知" value="unknown" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="搜索">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="MAC/区域/学校/楼宇/房间号"
|
||||
clearable
|
||||
@clear="search"
|
||||
@keyup.enter="search"
|
||||
style="width: 260px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item style="margin-left: auto">
|
||||
<el-button type="danger" plain @click="clearStatus" :loading="clearing">清空状态</el-button>
|
||||
<el-button type="warning" @click="refreshAllStatus" :loading="refreshing" style="margin-left: 8px">
|
||||
{{ refreshing ? '更新中...' : '全部更新' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<!-- 页头 -->
|
||||
<div class="page-header">
|
||||
<div class="page-title-group">
|
||||
<h1 class="page-title">设备列表</h1>
|
||||
<span class="page-subtitle">ONU 终端设备管理</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="danger" plain size="small" @click="clearStatus" :loading="clearing">
|
||||
清空状态
|
||||
</el-button>
|
||||
<el-button type="warning" size="small" @click="refreshAllStatus" :loading="refreshing">
|
||||
{{ refreshing ? '更新中…' : '全部更新' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="devices" border stripe>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="region" label="区域" width="100" />
|
||||
<el-table-column prop="school_name" label="学校名称" width="180" />
|
||||
<el-table-column prop="building" label="楼宇" width="130" />
|
||||
<el-table-column prop="place_type" label="场所类型" width="130" />
|
||||
<el-table-column prop="room_number" label="房间号" width="100" />
|
||||
<el-table-column prop="mac_address" label="MAC地址" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" @click="openDetail(row)">
|
||||
{{ formatMac(row.mac_address) }}
|
||||
</el-link>
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">区域</label>
|
||||
<el-select
|
||||
v-model="filters.region"
|
||||
placeholder="全部区域"
|
||||
clearable
|
||||
@change="search"
|
||||
size="small"
|
||||
style="width: 160px"
|
||||
>
|
||||
<el-option v-for="r in regions" :key="r" :label="r" :value="r" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">状态</label>
|
||||
<el-select
|
||||
v-model="filters.status"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
@change="search"
|
||||
size="small"
|
||||
style="width: 110px"
|
||||
>
|
||||
<el-option label="在线" value="online" />
|
||||
<el-option label="离线" value="offline" />
|
||||
<el-option label="未知" value="unknown" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label class="filter-label">搜索</label>
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="MAC / 区域 / 学校 / 楼宇 / 房间"
|
||||
clearable
|
||||
size="small"
|
||||
@clear="search"
|
||||
@keyup.enter="search"
|
||||
style="width: 280px"
|
||||
>
|
||||
<template #prefix>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.status === 'online' ? 'success' : row.status === 'offline' ? 'danger' : 'info'"
|
||||
size="small"
|
||||
>
|
||||
{{ row.status === 'online' ? '在线' : row.status === 'offline' ? '离线' : '未知' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="distance_m" label="距离" width="90" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ row.distance_m ? row.distance_m + 'm' : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="notes" label="备注" min-width="100" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-input>
|
||||
</div>
|
||||
<el-button type="primary" size="small" @click="search">查询</el-button>
|
||||
</div>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
:total="total"
|
||||
@current-change="loadDevices"
|
||||
@size-change="handleSizeChange"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
style="margin-top: 20px; justify-content: flex-end"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 检查结果对话框 -->
|
||||
<el-dialog v-model="checkResultVisible" title="全部更新结果" width="700px" destroy-on-close>
|
||||
<div v-if="checkInProgress" style="text-align: center; padding: 40px 0; color: #909399">
|
||||
正在扫描所有 OLT,请稍候...
|
||||
<!-- 数据面板 -->
|
||||
<div class="data-panel">
|
||||
<!-- 统计行 -->
|
||||
<div class="data-stats">
|
||||
<span class="data-total">共 <strong>{{ total }}</strong> 条记录</span>
|
||||
<div class="status-legend">
|
||||
<span class="legend-item">
|
||||
<span class="led online"></span>在线
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<span class="led offline"></span>离线
|
||||
</span>
|
||||
<span class="legend-item">
|
||||
<span class="led unknown"></span>未知
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
:data="devices"
|
||||
border
|
||||
stripe
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column type="index" label="#" width="52" align="center">
|
||||
<template #default="{ $index }">
|
||||
<span class="row-index">{{ (page - 1) * pageSize + $index + 1 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="region" label="区域" width="90" />
|
||||
<el-table-column prop="school_name" label="学校名称" width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="building" label="楼宇" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="place_type" label="场所类型" width="110" show-overflow-tooltip />
|
||||
<el-table-column prop="room_number" label="房间号" width="90" />
|
||||
<el-table-column prop="mac_address" label="MAC 地址" width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="mac-link" @click="openDetail(row)">
|
||||
{{ formatMac(row.mac_address) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="76" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="status-badge" :class="row.status">
|
||||
<span class="status-dot-sm"></span>
|
||||
{{ row.status === 'online' ? '在线' : row.status === 'offline' ? '离线' : '未知' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="distance_m" label="距离" width="80" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-val">{{ row.distance_m ? row.distance_m + 'm' : '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="notes" label="备注" min-width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span style="color: var(--text-muted); font-size: 12px">{{ row.notes || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
:total="total"
|
||||
@current-change="loadDevices"
|
||||
@size-change="handleSizeChange"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 检查结果对话框 -->
|
||||
<el-dialog v-model="checkResultVisible" title="全部更新结果" width="680px" destroy-on-close>
|
||||
<div v-if="checkInProgress" class="dialog-loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>正在扫描所有 OLT,请稍候…</span>
|
||||
</div>
|
||||
<div v-else-if="checkResult">
|
||||
<el-alert
|
||||
:type="checkResult.success ? 'success' : 'error'"
|
||||
@@ -93,27 +158,34 @@
|
||||
:closable="false"
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<template v-if="checkResult.success">
|
||||
<div style="display: flex; gap: 24px; margin-bottom: 16px">
|
||||
<el-statistic title="在线设备" :value="checkResult.total_online || 0" />
|
||||
<el-statistic title="离线设备" :value="checkResult.total_offline || 0" />
|
||||
<el-statistic title="失败 OLT" :value="(checkResult.errors || []).length" />
|
||||
<div class="result-stats">
|
||||
<div class="result-stat">
|
||||
<span class="rs-val online">{{ checkResult.total_online || 0 }}</span>
|
||||
<span class="rs-label">在线设备</span>
|
||||
</div>
|
||||
<div class="result-stat">
|
||||
<span class="rs-val offline">{{ checkResult.total_offline || 0 }}</span>
|
||||
<span class="rs-label">离线设备</span>
|
||||
</div>
|
||||
<div class="result-stat">
|
||||
<span class="rs-val warn">{{ (checkResult.errors || []).length }}</span>
|
||||
<span class="rs-label">失败 OLT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="checkResult.results || []" border stripe size="small" max-height="300">
|
||||
<el-table-column prop="olt_location" label="OLT名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="在线" width="80" align="center">
|
||||
<el-table :data="checkResult.results || []" border size="small" max-height="280">
|
||||
<el-table-column prop="olt_location" label="OLT 名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="在线" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="success" size="small">{{ row.online || 0 }}</el-tag>
|
||||
<span class="mono-val" style="color: var(--success)">{{ row.online || 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="离线" width="80" align="center">
|
||||
<el-table-column label="离线" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="danger" size="small">{{ row.offline || 0 }}</el-tag>
|
||||
<span class="mono-val" style="color: var(--danger)">{{ row.offline || 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90" align="center">
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.success ? 'success' : 'danger'" size="small">
|
||||
{{ row.success ? '成功' : '失败' }}
|
||||
@@ -123,7 +195,6 @@
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="checkResultVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="checkResultVisible = false; loadDevices()" v-if="!checkInProgress">
|
||||
@@ -135,35 +206,29 @@
|
||||
<!-- 设备详情对话框 -->
|
||||
<el-dialog v-model="detailVisible" title="设备详情" width="500px" destroy-on-close>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="MAC地址" :span="2">
|
||||
{{ formatMac(selectedDevice.mac_address) }}
|
||||
<el-descriptions-item label="MAC 地址" :span="2">
|
||||
<span class="mono-val accent">{{ formatMac(selectedDevice.mac_address) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="区域">{{ selectedDevice.region }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学校">{{ selectedDevice.school_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="楼宇">{{ selectedDevice.building || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="房间号">{{ selectedDevice.room_number || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="楼宇">{{ selectedDevice.building || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="房间号">{{ selectedDevice.room_number || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="selectedDevice.status === 'online' ? 'success' : selectedDevice.status === 'offline' ? 'danger' : 'info'" size="small">
|
||||
<span class="status-badge" :class="selectedDevice.status">
|
||||
<span class="status-dot-sm"></span>
|
||||
{{ selectedDevice.status === 'online' ? '在线' : selectedDevice.status === 'offline' ? '离线' : '未知' }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="距离">
|
||||
{{ selectedDevice.distance_m ? selectedDevice.distance_m + ' 米' : '-' }}
|
||||
<span class="mono-val">{{ selectedDevice.distance_m ? selectedDevice.distance_m + ' 米' : '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="端口" :span="2">
|
||||
{{ formatPort(selectedDevice) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="型号" :span="2">
|
||||
{{ selectedDevice.model || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="所属OLT" :span="2">
|
||||
{{ selectedDevice.olt_location || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">
|
||||
{{ selectedDevice.notes || '-' }}
|
||||
<span class="mono-val">{{ formatPort(selectedDevice) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="型号" :span="2">{{ selectedDevice.model || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属 OLT" :span="2">{{ selectedDevice.olt_location || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ selectedDevice.notes || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template #footer>
|
||||
<el-button type="info" :loading="deviceRefreshing" @click="doRefreshDevice" :disabled="!selectedDevice.olt_id">更新</el-button>
|
||||
<el-button type="primary" @click="openEdit">编辑</el-button>
|
||||
@@ -175,23 +240,13 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<el-dialog v-model="editVisible" title="编辑设备信息" width="480px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="80px">
|
||||
<el-form-item label="区域">
|
||||
<el-input v-model="editForm.region" />
|
||||
</el-form-item>
|
||||
<el-form-item label="学校">
|
||||
<el-input v-model="editForm.school_name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="楼宇">
|
||||
<el-input v-model="editForm.building" />
|
||||
</el-form-item>
|
||||
<el-form-item label="房间号">
|
||||
<el-input v-model="editForm.room_number" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="editForm.notes" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
<el-dialog v-model="editVisible" title="编辑设备信息" width="460px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="70px">
|
||||
<el-form-item label="区域"><el-input v-model="editForm.region" /></el-form-item>
|
||||
<el-form-item label="学校"><el-input v-model="editForm.school_name" /></el-form-item>
|
||||
<el-form-item label="楼宇"><el-input v-model="editForm.building" /></el-form-item>
|
||||
<el-form-item label="房间号"><el-input v-model="editForm.room_number" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="editForm.notes" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
@@ -200,7 +255,7 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 业务下发对话框 -->
|
||||
<el-dialog v-model="provisionVisible" title="业务下发" width="560px" destroy-on-close>
|
||||
<el-dialog v-model="provisionVisible" title="业务下发" width="540px" destroy-on-close>
|
||||
<el-alert
|
||||
v-if="provisionResult"
|
||||
:type="provisionResult.success ? 'success' : 'error'"
|
||||
@@ -210,25 +265,30 @@
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<el-descriptions :column="2" border v-if="selectedDevice.mac_address">
|
||||
<el-descriptions-item label="MAC地址">{{ formatMac(selectedDevice.mac_address) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="端口">{{ formatPort(selectedDevice) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学校">{{ selectedDevice.school_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="MAC 地址">
|
||||
<span class="mono-val accent">{{ formatMac(selectedDevice.mac_address) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="端口">
|
||||
<span class="mono-val">{{ formatPort(selectedDevice) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="学校">{{ selectedDevice.school_name || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="selectedDevice.status === 'online' ? 'success' : selectedDevice.status === 'offline' ? 'danger' : 'info'" size="small">
|
||||
<span class="status-badge" :class="selectedDevice.status">
|
||||
<span class="status-dot-sm"></span>
|
||||
{{ selectedDevice.status === 'online' ? '在线' : selectedDevice.status === 'offline' ? '离线' : '未知' }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider>将执行的配置</el-divider>
|
||||
<el-text type="info" size="small">
|
||||
<pre style="margin: 0; font-size: 12px; line-height: 1.6">system-view
|
||||
<div class="cli-block">
|
||||
<pre>system-view
|
||||
interface {{ formatPort(selectedDevice) }}
|
||||
uni 1 vlan-mode trunk pvid 4094 2000 to 2000 3000 to 3010
|
||||
port link-type trunk
|
||||
undo port trunk permit vlan 1
|
||||
port trunk permit vlan 2000 3000 to 3010 4094
|
||||
save force</pre>
|
||||
</el-text>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="provisionVisible = false; provisionResult = null">关闭</el-button>
|
||||
<el-button
|
||||
@@ -259,29 +319,24 @@ const pageSize = ref(20)
|
||||
const regions = ref([])
|
||||
const filters = ref({ region: '', keyword: '', school_name: '', status: '' })
|
||||
|
||||
// 详情对话框
|
||||
const detailVisible = ref(false)
|
||||
const selectedDevice = ref({})
|
||||
const deviceRefreshing = ref(false)
|
||||
|
||||
// 业务下发对话框
|
||||
const provisionVisible = ref(false)
|
||||
const provisionLoading = ref(false)
|
||||
const provisionResult = ref(null)
|
||||
const refreshing = ref(false)
|
||||
const clearing = ref(false)
|
||||
|
||||
// 编辑对话框
|
||||
const editVisible = ref(false)
|
||||
const editForm = ref({ region: '', school_name: '', building: '', room_number: '', notes: '' })
|
||||
const editSaving = ref(false)
|
||||
|
||||
// 检查结果对话框
|
||||
const checkResultVisible = ref(false)
|
||||
const checkResult = ref(null)
|
||||
const checkInProgress = ref(false)
|
||||
|
||||
// 清空所有设备状态
|
||||
const clearStatus = async () => {
|
||||
clearing.value = true
|
||||
try {
|
||||
@@ -295,13 +350,11 @@ const clearStatus = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新所有设备状态
|
||||
const refreshAllStatus = async () => {
|
||||
refreshing.value = true
|
||||
checkResultVisible.value = true
|
||||
checkInProgress.value = true
|
||||
checkResult.value = null
|
||||
|
||||
try {
|
||||
const { data } = await deviceApi.refreshAllStatus()
|
||||
checkResult.value = {
|
||||
@@ -319,7 +372,6 @@ const refreshAllStatus = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化 MAC 地址
|
||||
const formatMac = (mac) => {
|
||||
if (!mac) return ''
|
||||
const clean = mac.toUpperCase().replace(/[:-]/g, '')
|
||||
@@ -329,27 +381,25 @@ const formatMac = (mac) => {
|
||||
clean.substring(8, 12).toLowerCase()
|
||||
}
|
||||
|
||||
// 格式化端口显示
|
||||
const formatPort = (device) => {
|
||||
if (device.port_id) return `Onu${device.port_id}`
|
||||
if (!device.slot_number && !device.port_number) return '-'
|
||||
if (!device.slot_number && !device.port_number) return '—'
|
||||
return `Onu${device.slot_number || '?'}/${device.port_number || '?'}`
|
||||
}
|
||||
|
||||
// 打开详情对话框
|
||||
const openDetail = (row) => {
|
||||
selectedDevice.value = { ...row }
|
||||
provisionResult.value = null
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
// 单台设备状态更新
|
||||
const doRefreshDevice = async () => {
|
||||
deviceRefreshing.value = true
|
||||
try {
|
||||
const { data } = await deviceApi.refreshDevice(selectedDevice.value.id)
|
||||
selectedDevice.value.status = data.status
|
||||
selectedDevice.value.distance_m = data.distance_m
|
||||
if (data.model) selectedDevice.value.model = data.model
|
||||
ElMessage.success(`更新完成:${data.status === 'online' ? '在线' : '离线'}${data.distance_m ? ',距离 ' + data.distance_m + 'm' : ''}`)
|
||||
loadDevices()
|
||||
} catch (error) {
|
||||
@@ -359,7 +409,6 @@ const doRefreshDevice = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开编辑对话框
|
||||
const openEdit = () => {
|
||||
editForm.value = {
|
||||
region: selectedDevice.value.region || '',
|
||||
@@ -386,13 +435,11 @@ const saveEdit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开业务下发对话框
|
||||
const openProvision = () => {
|
||||
provisionResult.value = null
|
||||
provisionVisible.value = true
|
||||
}
|
||||
|
||||
// 执行业务下发
|
||||
const doProvision = async () => {
|
||||
provisionLoading.value = true
|
||||
try {
|
||||
@@ -408,7 +455,6 @@ const doProvision = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 加载区域列表
|
||||
const loadRegions = async () => {
|
||||
const { data } = await deviceApi.getRegions()
|
||||
regions.value = data
|
||||
@@ -449,6 +495,261 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.device-list {
|
||||
padding: 20px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* 页头 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 18px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 数据面板 */
|
||||
.data-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.data-total {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.data-total strong {
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.status-legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.led {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.led.online { background: var(--success); box-shadow: 0 0 5px var(--success); }
|
||||
.led.offline { background: var(--danger); }
|
||||
.led.unknown { background: var(--text-muted); }
|
||||
|
||||
/* 表格中的元素 */
|
||||
.row-index {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mac-link {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.02em;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.mac-link:hover {
|
||||
color: #00f0cc;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-badge.online {
|
||||
background: var(--success-dim);
|
||||
color: var(--success);
|
||||
border: 1px solid rgba(34,197,94,0.25);
|
||||
}
|
||||
|
||||
.status-badge.offline {
|
||||
background: var(--danger-dim);
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(239,68,68,0.25);
|
||||
}
|
||||
|
||||
.status-badge.unknown {
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.status-dot-sm {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.online .status-dot-sm { box-shadow: 0 0 4px currentColor; }
|
||||
|
||||
.mono-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mono-val.accent { color: var(--accent); font-size: 13px; }
|
||||
|
||||
/* 分页 */
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 14px 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* 对话框内元素 */
|
||||
.dialog-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 48px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid var(--border-default);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.result-stats {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
padding: 16px 0;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.result-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rs-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rs-val.online { color: var(--success); }
|
||||
.rs-val.offline { color: var(--danger); }
|
||||
.rs-val.warn { color: var(--warning); }
|
||||
|
||||
.rs-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* CLI 预览块 */
|
||||
.cli-block {
|
||||
background: #0a0e1a;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cli-block pre {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: #a3e6d8;
|
||||
line-height: 1.7;
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,81 +1,143 @@
|
||||
<template>
|
||||
<div class="import-page">
|
||||
<el-card>
|
||||
<div class="header">
|
||||
<h2>数据导入</h2>
|
||||
<el-button type="success" @click="handleDownloadTemplate">
|
||||
<el-icon><Download /></el-icon>
|
||||
下载模板
|
||||
</el-button>
|
||||
<div class="page-header">
|
||||
<div class="page-title-group">
|
||||
<h1 class="page-title">数据导入</h1>
|
||||
<span class="page-subtitle">批量导入 ONU 设备信息</span>
|
||||
</div>
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
accept=".xlsx,.xls"
|
||||
>
|
||||
<el-button type="primary">选择文件</el-button>
|
||||
</el-upload>
|
||||
<el-button @click="handleUpload" :loading="uploading" style="margin-top: 20px">
|
||||
开始导入
|
||||
</el-button>
|
||||
<div v-if="result" style="margin-top: 20px">
|
||||
<el-alert
|
||||
:title="`成功: ${result.success}`"
|
||||
type="success"
|
||||
:closable="false"
|
||||
style="margin-bottom: 10px"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="result.failed && result.failed.length > 0"
|
||||
:title="`失败: ${result.failed.length}`"
|
||||
type="error"
|
||||
:closable="false"
|
||||
style="margin-bottom: 10px"
|
||||
/>
|
||||
<el-table
|
||||
v-if="result.failed && result.failed.length > 0"
|
||||
:data="result.failed"
|
||||
border
|
||||
style="margin-top: 10px"
|
||||
max-height="300"
|
||||
>
|
||||
<el-table-column label="MAC地址" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.record?.mac_address || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="record.school_name" label="学校名称" width="180" />
|
||||
<el-table-column prop="error" label="失败原因" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="import-grid">
|
||||
<!-- 上传区 -->
|
||||
<div class="import-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">上传文件</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="step-hint">
|
||||
<div class="step-item">
|
||||
<span class="step-num">1</span>
|
||||
<span>下载模板文件,按格式填写设备信息</span>
|
||||
</div>
|
||||
<div class="step-item">
|
||||
<span class="step-num">2</span>
|
||||
<span>选择填写好的 Excel 文件(.xlsx / .xls)</span>
|
||||
</div>
|
||||
<div class="step-item">
|
||||
<span class="step-num">3</span>
|
||||
<span>点击「开始导入」,等待处理完成</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
:show-file-list="false"
|
||||
accept=".xlsx,.xls"
|
||||
drag
|
||||
class="upload-zone"
|
||||
>
|
||||
<div class="upload-inner">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="upload-icon">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
<div class="upload-text">
|
||||
<span v-if="!selectedFile">拖拽文件到此处,或 <em>点击选择</em></span>
|
||||
<span v-else class="file-selected">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="color: var(--success); vertical-align: -2px">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
{{ selectedFile.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="upload-sub">支持 .xlsx .xls 格式</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<div class="action-row">
|
||||
<el-button @click="handleDownloadTemplate" size="default">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 5px">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
下载模板
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="default"
|
||||
:loading="uploading"
|
||||
:disabled="!selectedFile"
|
||||
@click="handleUpload"
|
||||
>
|
||||
{{ uploading ? '导入中…' : '开始导入' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 结果区 -->
|
||||
<div class="import-panel result-panel" v-if="result">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">导入结果</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="result-summary">
|
||||
<div class="rs-card success">
|
||||
<span class="rs-num">{{ result.success }}</span>
|
||||
<span class="rs-label">成功导入</span>
|
||||
</div>
|
||||
<div class="rs-card" :class="result.failed?.length ? 'failed' : 'empty'">
|
||||
<span class="rs-num">{{ result.failed?.length || 0 }}</span>
|
||||
<span class="rs-label">导入失败</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="result.failed?.length" class="failed-table-wrap">
|
||||
<div class="failed-title">失败记录详情</div>
|
||||
<el-table :data="result.failed" border size="small" max-height="280">
|
||||
<el-table-column label="MAC 地址" width="155">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-val">{{ row.record?.mac_address || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="record.school_name" label="学校名称" width="160" />
|
||||
<el-table-column prop="error" label="失败原因">
|
||||
<template #default="{ row }">
|
||||
<span style="color: var(--danger); font-size: 12px">{{ row.error }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import * as importApi from '../api/import'
|
||||
|
||||
const file = ref(null)
|
||||
const selectedFile = ref(null)
|
||||
const uploading = ref(false)
|
||||
const result = ref(null)
|
||||
|
||||
const handleFileChange = (uploadFile) => {
|
||||
file.value = uploadFile.raw
|
||||
selectedFile.value = uploadFile
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file.value) {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.value)
|
||||
|
||||
formData.append('file', selectedFile.value.raw)
|
||||
try {
|
||||
const { data } = await importApi.uploadExcel(formData)
|
||||
result.value = data
|
||||
@@ -109,12 +171,219 @@ const handleDownloadTemplate = async () => {
|
||||
|
||||
<style scoped>
|
||||
.import-page {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.import-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.import-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
|
||||
/* 步骤提示 */
|
||||
.step-hint {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--border-accent);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* 上传区域 */
|
||||
.upload-zone :deep(.el-upload) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-zone :deep(.el-upload-dragger) {
|
||||
background: var(--bg-elevated) !important;
|
||||
border: 2px dashed var(--border-default) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
transition: border-color 0.2s, background 0.2s !important;
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.upload-zone :deep(.el-upload-dragger:hover) {
|
||||
border-color: var(--accent) !important;
|
||||
background: var(--accent-dim) !important;
|
||||
}
|
||||
|
||||
.upload-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 36px 24px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.upload-text em {
|
||||
font-style: normal;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--success);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 操作行 */
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 结果 */
|
||||
.result-summary {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.rs-card {
|
||||
flex: 1;
|
||||
padding: 18px 20px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.rs-card.success {
|
||||
background: var(--success-dim);
|
||||
border-color: rgba(34,197,94,0.25);
|
||||
}
|
||||
|
||||
.rs-card.failed {
|
||||
background: var(--danger-dim);
|
||||
border-color: rgba(239,68,68,0.25);
|
||||
}
|
||||
|
||||
.rs-card.empty {
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.rs-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rs-card.success .rs-num { color: var(--success); }
|
||||
.rs-card.failed .rs-num { color: var(--danger); }
|
||||
.rs-card.empty .rs-num { color: var(--text-muted); }
|
||||
|
||||
.rs-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.failed-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mono-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
+197
-24
@@ -1,23 +1,52 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<el-card class="login-card">
|
||||
<h2>H3C ONU 设备管理系统</h2>
|
||||
<div v-if="!error">
|
||||
<el-icon class="is-loading" style="font-size: 24px; margin-bottom: 12px"><Loading /></el-icon>
|
||||
<p>正在跳转到登录页面...</p>
|
||||
<div class="login-bg">
|
||||
<!-- 背景网格 -->
|
||||
<div class="bg-grid"></div>
|
||||
|
||||
<!-- 中心卡片 -->
|
||||
<div class="login-card">
|
||||
<div class="card-brand">
|
||||
<div class="brand-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="2" y="2" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.9"/>
|
||||
<rect x="14" y="2" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="2" y="14" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.5"/>
|
||||
<rect x="14" y="14" width="8" height="8" rx="1.5" fill="currentColor" opacity="0.9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="brand-name">H3C ONU</div>
|
||||
<div class="brand-sub">设备管理系统</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-alert type="error" :title="error" :closable="false" style="margin-bottom: 16px; text-align: left" />
|
||||
<el-button type="primary" @click="doRedirect">重试</el-button>
|
||||
|
||||
<div v-if="!error" class="login-status">
|
||||
<div class="spin-ring"></div>
|
||||
<p class="status-text">正在跳转到登录页面…</p>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-else class="login-error">
|
||||
<div class="error-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="error-msg">{{ error }}</p>
|
||||
<button class="retry-btn" @click="doRedirect">重试连接</button>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<span class="version-tag">v0.5.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getLoginUrl } from '../api/auth'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
|
||||
const error = ref('')
|
||||
|
||||
@@ -35,21 +64,165 @@ onMounted(doRedirect)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
.login-bg {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-base);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 背景网格 */
|
||||
.bg-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0,210,180,0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,210,180,0.04) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
mask-image: radial-gradient(ellipse 80% 80% at 50% 50%, black 30%, transparent 100%);
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 380px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 16px;
|
||||
padding: 40px 36px;
|
||||
box-shadow: 0 32px 80px rgba(0,0,0,0.5), 0 0 0 1px rgba(0,210,180,0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
/* 品牌 */
|
||||
.card-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--border-accent);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.login-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.spin-ring {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 2px solid var(--border-default);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.status-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.login-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--danger-dim);
|
||||
border: 1px solid rgba(239,68,68,0.25);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #0a0e1a;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: #00f0cc;
|
||||
box-shadow: 0 0 16px var(--accent-glow);
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.card-footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.login-card {
|
||||
width: 400px;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
|
||||
.version-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
padding: 3px 10px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
}
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
p { color: #666; margin-bottom: 0; }
|
||||
</style>
|
||||
|
||||
+719
-212
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"frontend-design": {
|
||||
"source": "anthropics/skills",
|
||||
"sourceType": "github",
|
||||
"computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67"
|
||||
}
|
||||
}
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills/frontend-design
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
1. 查看所有接口简要状态`
|
||||
`,olt输出示例如下:“Olt1/0/1”就是OLT的端口名称,“UP”表示当前端口是开启的状态;“DOWN”表示当前端口是关闭的状态;
|
||||
```
|
||||
<8OLT>display interface brief
|
||||
Brief information on interfaces in route mode:
|
||||
Link: ADM - administratively down; Stby - standby
|
||||
Protocol: (s) - spoofing
|
||||
Interface Link Protocol Primary IP Description
|
||||
InLoop0 UP UP(s) --
|
||||
Loop100 UP UP(s) 172.16.0.72
|
||||
NULL0 UP UP(s) --
|
||||
REG0 UP -- --
|
||||
Vlan100 UP UP 172.16.3.29
|
||||
Vlan2000 UP UP 172.21.35.254 WIFI-user
|
||||
Vlan3000 UP UP 172.31.35.254 PC-user
|
||||
Vlan4094 UP UP 172.17.36.254 AP-Manage
|
||||
|
||||
Brief information on interfaces in bridge mode:
|
||||
Link: ADM - administratively down; Stby - standby
|
||||
Speed: (a) - auto
|
||||
Duplex: (a)/A - auto; H - half; F - full
|
||||
Type: A - access; T - trunk; H - hybrid
|
||||
Interface Link Speed Duplex Type PVID Description
|
||||
GE1/0/9 DOWN auto A A 1
|
||||
GE1/0/10 DOWN auto A A 1
|
||||
GE1/0/11 DOWN auto A A 1
|
||||
GE1/0/12 DOWN auto A A 1
|
||||
GE1/0/13 DOWN auto A A 1
|
||||
GE1/0/14 DOWN auto A A 1
|
||||
GE1/0/15 DOWN auto A A 1
|
||||
GE1/0/16 UP 1G(a) F(a) T 100 TO-DH-BLCWXZHL-2F-3610
|
||||
Olt1/0/1 UP -- -- T 1
|
||||
Olt1/0/2 UP -- -- T 1
|
||||
Olt1/0/3 UP -- -- T 1
|
||||
Olt1/0/4 DOWN -- -- T 1
|
||||
Olt1/0/5 UP -- -- T 1
|
||||
Olt1/0/6 DOWN -- -- T 1
|
||||
Olt1/0/7 UP -- -- T 1
|
||||
Olt1/0/8 DOWN -- -- T 1
|
||||
Onu1/0/1:1 DOWN -- -- T 1
|
||||
Onu1/0/1:2 DOWN -- -- T 1
|
||||
Onu1/0/1:3 DOWN -- -- T 1
|
||||
Onu1/0/1:4 DOWN -- -- T 1
|
||||
Onu1/0/1:5 DOWN -- -- T 1
|
||||
Onu1/0/1:6 UP -- -- T 1
|
||||
Onu1/0/1:7 UP -- -- T 1
|
||||
Onu1/0/1:8 DOWN -- -- T 1
|
||||
|
||||
```
|
||||
2. 进入系统视图`system-view`
|
||||
3. 进入端口视图`interface Olt1/0/1`
|
||||
4. 关闭端口`shutdown`& 开启端口`undo shutdown`
|
||||
5. 退出`quit`
|
||||
@@ -1,9 +1,165 @@
|
||||
# H3C ONU设备管理系统 - 开发进度
|
||||
|
||||
## 项目状态
|
||||
**当前版本**: v0.5.0
|
||||
**开发日期**: 2026年4月1日
|
||||
**状态**: 核心功能已完成
|
||||
**当前版本**: v0.6.0
|
||||
**开发日期**: 2026年4月3日
|
||||
**状态**: 核心功能已完成,持续迭代优化
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成功能
|
||||
|
||||
### 第一阶段:项目基础搭建 (2周) - 100%
|
||||
|
||||
#### 第1周:环境搭建和基础框架 ✅
|
||||
- ✅ 创建FastAPI项目结构
|
||||
- ✅ 配置数据库连接(PostgreSQL + SQLAlchemy)
|
||||
- ✅ 设置Alembic数据库迁移
|
||||
- ✅ 创建基础配置管理
|
||||
- ✅ 创建Vue 3项目(Vite)
|
||||
- ✅ 集成Element Plus UI库
|
||||
- ✅ 配置路由系统(Vue Router)
|
||||
- ✅ 设置状态管理(Pinia)
|
||||
- ✅ 配置API请求封装(Axios)
|
||||
- ✅ 创建Dockerfile(前后端)
|
||||
- ✅ 编写docker-compose.yml
|
||||
- ✅ 设置环境变量管理
|
||||
|
||||
#### 第2周:认证系统和基础API ✅
|
||||
- ✅ 集成Casdoor SDK
|
||||
- ✅ 实现OAuth 2.0登录流程
|
||||
- ✅ 创建JWT令牌管理
|
||||
- ✅ 实现用户信息同步
|
||||
- ✅ 创建基础CRUD API框架
|
||||
- ✅ 实现API文档(Swagger/ReDoc)
|
||||
- ✅ 创建登录状态管理
|
||||
- ✅ 创建基础布局组件
|
||||
- ✅ 实现API调用封装
|
||||
|
||||
---
|
||||
|
||||
### 第二阶段:核心功能开发 (4周) - 100%
|
||||
|
||||
#### 第3周:数据库设计和设备模型 ✅
|
||||
- ✅ 设计并创建数据库表结构
|
||||
- ✅ olt_devices表
|
||||
- ✅ onu_devices表
|
||||
- ✅ device_status_history表
|
||||
- ✅ users表
|
||||
- ✅ permissions表
|
||||
- ✅ 创建SQLAlchemy模型
|
||||
- ✅ 创建设备管理API
|
||||
- ✅ 实现数据验证(Pydantic)
|
||||
- ✅ 创建设备列表页面
|
||||
- ✅ 实现表格组件
|
||||
- ✅ 创建分页组件
|
||||
- ✅ 实现搜索和筛选功能
|
||||
- ✅ 初始化数据库脚本
|
||||
|
||||
#### 第4周:SSH连接和状态检查 ✅
|
||||
- ✅ 集成Paramiko SSH库
|
||||
- ✅ 创建命令执行和结果解析
|
||||
- ✅ 实现设备状态检查服务
|
||||
- ✅ 集成Celery异步任务
|
||||
- ✅ 配置Redis消息队列
|
||||
- ✅ 创建状态检查控制面板
|
||||
- ✅ 实现手动刷新功能
|
||||
- ✅ 支持"More"分页处理
|
||||
|
||||
#### 第5周:Excel导入和数据管理 ✅
|
||||
- ✅ 集成Pandas库
|
||||
- ✅ 实现Excel文件解析
|
||||
- ✅ 创建数据导入服务
|
||||
- ✅ 实现数据验证和清洗
|
||||
- ✅ 创建批量导入API
|
||||
- ✅ 创建数据导入页面
|
||||
- ✅ 实现文件上传组件
|
||||
- ✅ 实现导入进度显示
|
||||
|
||||
#### 第6周:权限管理和统计 ✅
|
||||
- ✅ 设计权限模型(RBAC)
|
||||
- ✅ 创建权限检查中间件
|
||||
- ✅ 创建统计API
|
||||
- ✅ 实现仪表板统计卡片
|
||||
- ✅ 创建布局和导航组件
|
||||
- ✅ Celery Beat定时任务(每30分钟)
|
||||
|
||||
---
|
||||
|
||||
### 第三阶段:高级功能迭代 - 进行中
|
||||
|
||||
#### 统计图表和可视化 ✅
|
||||
- ✅ ECharts图表集成
|
||||
- ✅ 设备状态趋势图
|
||||
- ✅ 区域/学校统计图表
|
||||
- [ ] 历史数据可视化
|
||||
|
||||
#### OLT设备管理增强 ✅
|
||||
- ✅ OLT设备配置管理(增删改查)
|
||||
- ✅ OLT批量导入(Excel)
|
||||
- ✅ 重复MAC地址检测与管理
|
||||
- ✅ 离线端口清除功能(SSH执行 `default` 命令)
|
||||
- ✅ 新发现设备入库管理(区域/学校信息补全)
|
||||
- ✅ 环路检测功能(`display mac-address` 分析)
|
||||
- ✅ 快速扫描(多线程并发扫描所有OLT)
|
||||
- ✅ 扫描并入库(自动发现新设备)
|
||||
|
||||
#### OLT端口管理 ✅(2026-04-03 新增)
|
||||
- ✅ 获取OLT所有端口状态(`display interface brief` 解析)
|
||||
- ✅ 交换机面板样式可视化(UP/DOWN/ADM状态色彩区分)
|
||||
- ✅ 点击端口开启/关闭(SSH执行 `shutdown`/`undo shutdown`)
|
||||
- ✅ OLT详情页新增"OLT端口管理"入口按钮
|
||||
|
||||
#### 单设备状态检查优化 ✅(2026-04-03 修复)
|
||||
- ✅ 修复"更新"按钮始终返回离线的问题(slot_command参数拼接错误)
|
||||
- ✅ 设备详情"型号"字段实时回写数据库并展示
|
||||
- ✅ 全部更新/快速扫描同步写入解析到的型号信息
|
||||
- ✅ 修复多线程扫描连接池耗尽问题(轻量化查询优化)
|
||||
|
||||
#### 系统设置和审核
|
||||
- [ ] 定时任务配置界面
|
||||
- [ ] 设备变更审核流程
|
||||
- [ ] 审核中心页面
|
||||
|
||||
#### 性能优化
|
||||
- [ ] 数据库查询优化
|
||||
- [ ] Redis缓存策略
|
||||
- [ ] 前端资源优化
|
||||
- [ ] 懒加载实现
|
||||
|
||||
---
|
||||
|
||||
## 🎯 核心功能清单
|
||||
|
||||
| 功能模块 | 状态 | 完成度 |
|
||||
|---------|------|--------|
|
||||
| 项目框架 | ✅ | 100% |
|
||||
| 认证系统 | ✅ | 100% |
|
||||
| 设备管理 | ✅ | 100% |
|
||||
| SSH连接 | ✅ | 100% |
|
||||
| 状态检查 | ✅ | 100% |
|
||||
| Excel导入 | ✅ | 100% |
|
||||
| 权限管理 | ✅ | 80% |
|
||||
| 统计功能 | ✅ | 85% |
|
||||
| 图表可视化 | ✅ | 75% |
|
||||
| OLT端口管理 | ✅ | 100% |
|
||||
| 环路检测 | ✅ | 100% |
|
||||
| 审核流程 | ⏳ | 0% |
|
||||
|
||||
---
|
||||
|
||||
## 📊 开发统计
|
||||
|
||||
- **总任务数**: 80+
|
||||
- **已完成**: 72+
|
||||
- **完成率**: 90%
|
||||
- **代码行数**: 约5000+行
|
||||
- **文件数**: 55+个
|
||||
|
||||
---
|
||||
|
||||
**更新日期**: 2026年4月3日
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user