feat: 侧边栏分组图标与导航样式优化
- 分组支持 icon 字段,可按名称自动匹配或手动选择 - 左侧导航与总览卡片显示彩色 SVG 图标 - 优化侧栏链接圆角与选中态样式 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -479,6 +479,7 @@ def create_app() -> Flask:
|
|||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
g = ServiceGroup(
|
g = ServiceGroup(
|
||||||
name=form.name.data.strip(),
|
name=form.name.data.strip(),
|
||||||
|
icon=(form.icon.data or "").strip(),
|
||||||
sort_order=form.sort_order.data or 0,
|
sort_order=form.sort_order.data or 0,
|
||||||
)
|
)
|
||||||
db.session.add(g)
|
db.session.add(g)
|
||||||
@@ -497,6 +498,7 @@ def create_app() -> Flask:
|
|||||||
form = GroupForm(obj=g)
|
form = GroupForm(obj=g)
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
g.name = form.name.data.strip()
|
g.name = form.name.data.strip()
|
||||||
|
g.icon = (form.icon.data or "").strip()
|
||||||
g.sort_order = form.sort_order.data or 0
|
g.sort_order = form.sort_order.data or 0
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("分组已更新", "success")
|
flash("分组已更新", "success")
|
||||||
@@ -666,6 +668,17 @@ def _migrate_schema() -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
print("[nav] 已为 services 表添加 embed_kind 列。", flush=True)
|
print("[nav] 已为 services 表添加 embed_kind 列。", flush=True)
|
||||||
|
if "service_groups" in insp.get_table_names():
|
||||||
|
gcols = {c["name"] for c in insp.get_columns("service_groups")}
|
||||||
|
if "icon" not in gcols:
|
||||||
|
with db.engine.begin() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE service_groups ADD COLUMN icon VARCHAR(16) "
|
||||||
|
"NOT NULL DEFAULT ''"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print("[nav] 已为 service_groups 表添加 icon 列。", flush=True)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"[nav] 数据库结构迁移跳过或失败: {exc}", flush=True)
|
print(f"[nav] 数据库结构迁移跳过或失败: {exc}", flush=True)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,22 @@ class LoginForm(FlaskForm):
|
|||||||
|
|
||||||
class GroupForm(FlaskForm):
|
class GroupForm(FlaskForm):
|
||||||
name = StringField("分组名称", validators=[DataRequired(message="请输入分组名称")])
|
name = StringField("分组名称", validators=[DataRequired(message="请输入分组名称")])
|
||||||
|
icon = SelectField(
|
||||||
|
"图标",
|
||||||
|
choices=[
|
||||||
|
("", "自动(按分组名匹配)"),
|
||||||
|
("order", "下单 / 订单"),
|
||||||
|
("trade", "交易 / 行情"),
|
||||||
|
("review", "复盘 / 历史"),
|
||||||
|
("api", "API / 接口"),
|
||||||
|
("gate", "Gate / 扫单"),
|
||||||
|
("chart", "图表 / K线"),
|
||||||
|
("server", "服务器 / 面板"),
|
||||||
|
("folder", "文件夹(通用)"),
|
||||||
|
],
|
||||||
|
default="",
|
||||||
|
validators=[Optional()],
|
||||||
|
)
|
||||||
sort_order = IntegerField(
|
sort_order = IntegerField(
|
||||||
"排序(越小越靠前)",
|
"排序(越小越靠前)",
|
||||||
validators=[Optional(), NumberRange(min=-10**6, max=10**6)],
|
validators=[Optional(), NumberRange(min=-10**6, max=10**6)],
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class ServiceGroup(db.Model):
|
|||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
name = db.Column(db.String(120), nullable=False)
|
name = db.Column(db.String(120), nullable=False)
|
||||||
|
icon = db.Column(db.String(16), nullable=False, default="", server_default="")
|
||||||
sort_order = db.Column(db.Integer, nullable=False, default=0)
|
sort_order = db.Column(db.Integer, nullable=False, default=0)
|
||||||
|
|
||||||
services = db.relationship(
|
services = db.relationship(
|
||||||
@@ -33,6 +34,26 @@ class ServiceGroup(db.Model):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def resolve_icon_key(self) -> str:
|
||||||
|
key = (self.icon or "").strip().lower()
|
||||||
|
if key:
|
||||||
|
return key
|
||||||
|
name = self.name or ""
|
||||||
|
lower = name.lower()
|
||||||
|
if "下单" in name:
|
||||||
|
return "order"
|
||||||
|
if "交易" in name:
|
||||||
|
return "trade"
|
||||||
|
if "复盘" in name:
|
||||||
|
return "review"
|
||||||
|
if "api" in lower:
|
||||||
|
return "api"
|
||||||
|
if "gate" in lower or "扫单" in name:
|
||||||
|
return "gate"
|
||||||
|
if "k线" in lower or "k 线" in name or "chart" in lower:
|
||||||
|
return "chart"
|
||||||
|
return "folder"
|
||||||
|
|
||||||
|
|
||||||
class Service(db.Model):
|
class Service(db.Model):
|
||||||
__tablename__ = "services"
|
__tablename__ = "services"
|
||||||
|
|||||||
+125
-19
@@ -170,37 +170,130 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section {
|
.sidebar-section {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.35rem;
|
||||||
|
padding: 0 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section h2 {
|
.sidebar-group-title {
|
||||||
margin: 0 0 0.35rem;
|
display: flex;
|
||||||
padding: 0 1rem;
|
align-items: center;
|
||||||
font-size: 0.72rem;
|
gap: 0.55rem;
|
||||||
text-transform: uppercase;
|
margin: 0.85rem 0 0.45rem;
|
||||||
letter-spacing: 0.08em;
|
padding: 0.35rem 0.5rem;
|
||||||
color: var(--muted);
|
font-size: 0.78rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text);
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-group-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-group-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-group-icon svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-group-icon--order {
|
||||||
|
background: rgba(59, 130, 246, 0.16);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-group-icon--trade {
|
||||||
|
background: rgba(16, 185, 129, 0.16);
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-group-icon--review {
|
||||||
|
background: rgba(168, 85, 247, 0.16);
|
||||||
|
color: #c084fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-group-icon--api {
|
||||||
|
background: rgba(245, 158, 11, 0.16);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-group-icon--gate {
|
||||||
|
background: rgba(236, 72, 153, 0.16);
|
||||||
|
color: #f472b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-group-icon--chart {
|
||||||
|
background: rgba(45, 212, 191, 0.16);
|
||||||
|
color: #2dd4bf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-group-icon--server {
|
||||||
|
background: rgba(99, 102, 241, 0.16);
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-group-icon--folder {
|
||||||
|
background: rgba(148, 163, 184, 0.14);
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-empty {
|
||||||
|
padding: 0.35rem 0.65rem 0.5rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
display: block;
|
display: flex;
|
||||||
padding: 0.45rem 1rem;
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.52rem 0.7rem 0.52rem 0.85rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-left: 3px solid transparent;
|
border-left: none;
|
||||||
font-size: 0.9rem;
|
border-radius: 8px;
|
||||||
|
font-size: 0.88rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
background 0.15s ease,
|
||||||
|
color 0.15s ease,
|
||||||
|
box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
background: rgba(61, 139, 253, 0.08);
|
background: rgba(61, 139, 253, 0.1);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link.active {
|
.nav-link.active {
|
||||||
background: rgba(61, 139, 253, 0.15);
|
background: rgba(61, 139, 253, 0.18);
|
||||||
border-left-color: var(--accent);
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,12 +325,25 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dash-section-title {
|
.dash-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
margin: 0 0 0.85rem;
|
margin: 0 0 0.85rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
letter-spacing: 0.02em;
|
||||||
letter-spacing: 0.1em;
|
color: var(--text);
|
||||||
color: var(--muted);
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-section-title .sidebar-group-icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-section-title .sidebar-group-icon svg {
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-card-grid {
|
.service-card-grid {
|
||||||
|
|||||||
@@ -28,6 +28,14 @@
|
|||||||
<div class="errors">{{ form.name.errors[0] }}</div>
|
<div class="errors">{{ form.name.errors[0] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
{{ form.icon.label }}
|
||||||
|
{{ form.icon() }}
|
||||||
|
<p class="hint" style="margin: 0.35rem 0 0">留空则按分组名称自动匹配图标(如「交易」「Gate 扫单」)。</p>
|
||||||
|
{% if form.icon.errors %}
|
||||||
|
<div class="errors">{{ form.icon.errors[0] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
{{ form.sort_order.label }}
|
{{ form.sort_order.label }}
|
||||||
{{ form.sort_order() }}
|
{{ form.sort_order() }}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
|
<th>图标</th>
|
||||||
<th>名称</th>
|
<th>名称</th>
|
||||||
<th>排序</th>
|
<th>排序</th>
|
||||||
<th style="width: 200px">操作</th>
|
<th style="width: 200px">操作</th>
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
{% for g in groups %}
|
{% for g in groups %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ g.id }}</td>
|
<td>{{ g.id }}</td>
|
||||||
|
<td><code>{{ g.resolve_icon_key() }}</code></td>
|
||||||
<td>{{ g.name }}</td>
|
<td>{{ g.name }}</td>
|
||||||
<td>{{ g.sort_order }}</td>
|
<td>{{ g.sort_order }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -59,7 +61,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="hint">暂无分组,点击「新建分组」</td>
|
<td colspan="5" class="hint">暂无分组,点击「新建分组」</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
+13
-4
@@ -1,4 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "macros/group_icon.html" import render as group_icon %}
|
||||||
{% block title %}导航 · 本地导航{% endblock %}
|
{% block title %}导航 · 本地导航{% endblock %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
@@ -35,7 +36,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% for group, services in grouped %}
|
{% for group, services in grouped %}
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<h2>{{ group.name }}</h2>
|
<h2 class="sidebar-group-title">
|
||||||
|
{{ group_icon(group.resolve_icon_key()) }}
|
||||||
|
<span class="sidebar-group-name">{{ group.name }}</span>
|
||||||
|
</h2>
|
||||||
|
<div class="sidebar-links">
|
||||||
{% for svc in services %}
|
{% for svc in services %}
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
@@ -48,12 +53,13 @@
|
|||||||
data-embed-kind="{{ (svc.embed_kind or '')|e }}"
|
data-embed-kind="{{ (svc.embed_kind or '')|e }}"
|
||||||
data-service-id="{{ svc.id }}"
|
data-service-id="{{ svc.id }}"
|
||||||
data-name="{{ svc.name | e }}"
|
data-name="{{ svc.name | e }}"
|
||||||
>{{ svc.name }}</a
|
><span class="nav-link-text">{{ svc.name }}</span></a
|
||||||
>
|
>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="hint" style="padding: 0 1rem">该分组下暂无服务</div>
|
<div class="hint sidebar-empty">该分组下暂无服务</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="hint" style="padding: 1rem">
|
<div class="hint" style="padding: 1rem">
|
||||||
暂无分组与服务,请到「分组管理」「服务管理」添加。
|
暂无分组与服务,请到「分组管理」「服务管理」添加。
|
||||||
@@ -74,7 +80,10 @@
|
|||||||
<div class="service-dashboard" id="service-dashboard">
|
<div class="service-dashboard" id="service-dashboard">
|
||||||
{% for group, services in grouped %}
|
{% for group, services in grouped %}
|
||||||
<section class="dash-section">
|
<section class="dash-section">
|
||||||
<h3 class="dash-section-title">{{ group.name }}</h3>
|
<h3 class="dash-section-title">
|
||||||
|
{{ group_icon(group.resolve_icon_key()) }}
|
||||||
|
<span>{{ group.name }}</span>
|
||||||
|
</h3>
|
||||||
<div class="service-card-grid">
|
<div class="service-card-grid">
|
||||||
{% for svc in services %}
|
{% for svc in services %}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% macro render(icon_key) %}
|
||||||
|
{% set key = icon_key or 'folder' %}
|
||||||
|
<span class="sidebar-group-icon sidebar-group-icon--{{ key }}" aria-hidden="true">
|
||||||
|
{% if key == 'order' %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/>
|
||||||
|
<rect x="9" y="3" width="6" height="4" rx="1"/>
|
||||||
|
<path d="M9 12h6M9 16h6"/>
|
||||||
|
</svg>
|
||||||
|
{% elif key == 'trade' %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 17l6-6 4 4 8-8"/>
|
||||||
|
<path d="M14 7h7v7"/>
|
||||||
|
</svg>
|
||||||
|
{% elif key == 'review' %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="9"/>
|
||||||
|
<path d="M12 7v5l3 2"/>
|
||||||
|
</svg>
|
||||||
|
{% elif key == 'api' %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M8 9l-3 3 3 3"/>
|
||||||
|
<path d="M16 9l3 3-3 3"/>
|
||||||
|
<path d="M14 5l-4 14"/>
|
||||||
|
</svg>
|
||||||
|
{% elif key == 'gate' %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="2"/>
|
||||||
|
<path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>
|
||||||
|
<path d="M4.9 4.9l2.1 2.1M16.9 16.9l2.1 2.1M4.9 19.1l2.1-2.1M16.9 7.1l2.1-2.1"/>
|
||||||
|
</svg>
|
||||||
|
{% elif key == 'chart' %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 19V5"/>
|
||||||
|
<path d="M4 19h16"/>
|
||||||
|
<path d="M8 15V9M12 15V7M16 15v-4"/>
|
||||||
|
</svg>
|
||||||
|
{% elif key == 'server' %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="4" width="18" height="6" rx="2"/>
|
||||||
|
<rect x="3" y="14" width="18" height="6" rx="2"/>
|
||||||
|
<circle cx="7" cy="7" r="1" fill="currentColor" stroke="none"/>
|
||||||
|
<circle cx="7" cy="17" r="1" fill="currentColor" stroke="none"/>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7z"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endmacro %}
|
||||||
Reference in New Issue
Block a user