feat: 侧边栏分组图标与导航样式优化

- 分组支持 icon 字段,可按名称自动匹配或手动选择
- 左侧导航与总览卡片显示彩色 SVG 图标
- 优化侧栏链接圆角与选中态样式

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-05-30 18:06:42 +08:00
parent 11129cc3a0
commit f7ce6f1058
8 changed files with 250 additions and 24 deletions
+13
View File
@@ -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)
+16
View File
@@ -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)],
+21
View File
@@ -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
View File
@@ -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 {
+8
View File
@@ -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() }}
+3 -1
View File
@@ -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
View File
@@ -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
+51
View File
@@ -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 %}