Files
aios/overlays/user-control-center/templates/portal/me.html
T
2026-07-06 19:16:31 +08:00

1741 lines
98 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "portal/base.html" %}
{% block title %}{% if profile %}{{ profile.user_id }}的工作台{% else %}用户工作台{% endif %}{% endblock %}
{% block shell %}
<style>
:root {
--bg: #f5f7fb;
--card: #fff;
--text: #172033;
--muted: #687386;
--line: #e7ebf2;
--blue: #2563eb;
--purple: #7c3aed;
--green: #16a34a;
--orange: #d97706;
--red: #dc2626;
--soft: #eef4ff;
}
body {
margin: 0;
background: radial-gradient(circle at 5% 0%, #dbeafe 0, #f5f7fb 32%), var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
.app { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: 100vh; }
.side {
background: rgba(255,255,255,.84);
border-right: 1px solid var(--line);
padding: 24px;
position: sticky;
top: 0;
height: 100vh;
display: flex;
flex-direction: column;
}
.logo { display: flex; gap: 12px; align-items: center; margin-bottom: 28px; }
.mark {
width: 42px;
height: 42px;
border-radius: 14px;
background: linear-gradient(135deg, var(--blue), var(--purple));
color: #fff;
display: grid;
place-items: center;
font-weight: 900;
box-shadow: 0 12px 26px rgba(37,99,235,.2);
}
.logo h1 { font-size: 18px; margin: 0; }
.logo p { font-size: 12px; color: var(--muted); margin: 2px 0 0; }
.nav { display: grid; gap: 8px; }
.nav button {
border: 0;
background: transparent;
text-align: left;
padding: 12px 14px;
border-radius: 14px;
color: #3b4453;
cursor: pointer;
font-weight: 650;
font-size: 14px;
}
.nav button.active, .nav button:hover { background: var(--soft); color: var(--blue); }
.note {
margin-top: 28px;
padding: 15px;
border-radius: 8px;
background: linear-gradient(145deg, #eff6ff, #faf5ff);
border: 1px solid #dbeafe;
}
.note small { color: var(--muted); }
.note b { display: block; margin-top: 5px; overflow-wrap: anywhere; }
.side-actions {
margin-top: auto;
padding-top: 16px;
display: grid;
gap: 10px;
}
.side-actions .actions {
display: grid;
gap: 8px;
justify-content: stretch;
}
.side-actions .btn { width: 100%; text-align: center; box-sizing: border-box; }
.main { padding: 28px; min-width: 0; }
.msg {
padding: 10px 12px;
background: #eef6ff;
border: 1px solid #cfe6ff;
border-radius: 12px;
margin: 0 0 16px;
color: #174ea6;
font-size: 14px;
}
.top { display: flex; justify-content: space-between; gap: 18px; align-items: flex-start; margin-bottom: 20px; }
.user { display: flex; gap: 16px; align-items: center; min-width: 0; }
.avatar {
width: 64px;
height: 64px;
border-radius: 22px;
background: linear-gradient(135deg, #111827, var(--blue));
color: #fff;
display: grid;
place-items: center;
font-size: 24px;
font-weight: 900;
flex: 0 0 auto;
}
.top h2 { font-size: 30px; margin: 0 0 8px; letter-spacing: 0; line-height: 1.16; }
.tags { display: flex; gap: 8px; flex-wrap: wrap; }
.tag {
font-size: 12px;
border: 1px solid var(--line);
background: #fff;
border-radius: 99px;
padding: 6px 10px;
color: #4b5563;
line-height: 1.2;
overflow-wrap: anywhere;
}
.tag.blue { background: #eaf1ff; color: var(--blue); border-color: #c9d9ff; }
.tag.green { background: #eaf8ef; color: var(--green); border-color: #c4eacc; }
.tag.orange { background: #fff7e8; color: var(--orange); border-color: #ffe2a9; }
.tag.red { background: #fff0f0; color: var(--red); border-color: #ffc7c7; }
.tag.purple { background: #f3edff; color: var(--purple); border-color: #decfff; }
.btn, button.btn {
border: 1px solid var(--line);
background: #fff;
color: var(--text);
border-radius: 12px;
padding: 10px 14px;
font-weight: 750;
cursor: pointer;
font-size: 14px;
min-height: 40px;
width: auto;
margin: 0;
text-decoration: none;
}
.btn.primary { background: var(--blue); color: white; border-color: var(--blue); }
.btn.danger { background: var(--red); color: white; border-color: var(--red); }
.btn[disabled] { opacity: .58; cursor: not-allowed; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
.actions form { margin: 0; }
.role-switch {
display: grid;
gap: 5px;
min-width: 168px;
}
.role-switch label {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.role-switch select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
min-height: 40px;
background: #fff;
color: var(--text);
font-weight: 750;
}
.channel-actions { align-items: flex-end; }
.channel-status-summary {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-top: 10px;
}
.btn.small { min-height: 32px; padding: 7px 10px; font-size: 12px; }
.btn.ghost { background: transparent; }
.channel-title-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.channel-title-row b { overflow-wrap: anywhere; }
.name-edit-button {
border: 0;
background: transparent;
color: var(--blue);
cursor: pointer;
font-size: 12px;
font-weight: 750;
padding: 3px 4px;
}
.name-edit-button:hover { text-decoration: underline; }
.remark-inline-form[hidden] { display: none; }
.remark-inline-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
.remark-inline-form label { color: var(--muted); font-size: 12px; font-weight: 650; }
.remark-inline-form input {
border: 1px solid var(--line);
border-radius: 10px;
padding: 9px 10px;
min-height: 38px;
min-width: 150px;
max-width: 220px;
font-size: 13px;
}
.confirm-backdrop[hidden] { display: none; }
.confirm-backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(15, 23, 42, .42);
display: grid;
place-items: center;
padding: 18px;
}
.confirm-dialog {
width: min(440px, 100%);
background: #fff;
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: 0 24px 70px rgba(15, 23, 42, .22);
padding: 20px;
}
.confirm-dialog h3 { margin: 0 0 8px; font-size: 18px; }
.confirm-dialog p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.7; }
.confirm-dialog .button-row { justify-content: flex-end; }
.metrics { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 16px; }
.metric, .card {
background: rgba(255,255,255,.95);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
box-shadow: 0 12px 34px rgba(15,23,42,.05);
}
.metric small { color: var(--muted); }
.metric strong { font-size: 27px; display: block; margin: 10px 0 6px; line-height: 1.1; overflow-wrap: anywhere; }
.metric span { font-size: 12px; color: var(--muted); }
.metric[role="button"], .box[role="button"] { cursor: pointer; }
.metric[role="button"]:hover, .box[role="button"]:hover { border-color: #c9d9ff; box-shadow: 0 14px 34px rgba(37,99,235,.1); }
.grid { display: grid; grid-template-columns: 1.35fr .85fr; gap: 18px; margin-top: 18px; }
.section { display: none; margin-top: 18px; }
.section.active { display: block; }
.title { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; margin-bottom: 14px; }
.title h3 { margin: 0; font-size: 18px; }
.title p { margin: 5px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
.chart {
height: 245px;
border-radius: 8px;
border: 1px solid #edf0f7;
background: linear-gradient(#fff,#f9fbff);
overflow: hidden;
position: relative;
}
.chart svg { width: 100%; height: 100%; display: block; }
.chart[data-usage-trend] svg { height: calc(100% - 34px); }
.chart-days {
position: absolute;
left: 40px;
right: 36px;
bottom: 8px;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 4px;
color: var(--muted);
font-size: 11px;
text-align: center;
}
.legend { font-size: 12px; color: var(--muted); display: flex; gap: 16px; margin-top: 12px; flex-wrap: wrap; }
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; }
.stack { display: grid; gap: 12px; }
.mini, .item, .box {
background: #fff;
border: 1px solid #edf0f7;
border-radius: 8px;
padding: 14px;
}
.mini b { display: block; margin-bottom: 6px; }
.mini p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
.bar { height: 8px; border-radius: 999px; background: #eef2f7; overflow: hidden; margin-top: 10px; }
.bar i { display: block; height: 100%; width: 26%; background: linear-gradient(90deg,var(--blue),var(--purple)); }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { color: var(--muted); text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
td { padding: 13px 8px; border-bottom: 1px solid #f0f2f5; }
.agent { display: flex; gap: 10px; align-items: center; }
.icon, .glyph {
width: 34px;
height: 34px;
border-radius: 12px;
background: var(--blue);
color: #fff;
display: grid;
place-items: center;
font-weight: 900;
flex: 0 0 auto;
}
.glyph { background: #eaf1ff; color: var(--blue); }
.list { display: grid; gap: 10px; }
.item { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.left { display: flex; gap: 10px; align-items: center; min-width: 0; }
.muted { color: var(--muted); font-size: 12px; margin-top: 3px; line-height: 1.5; }
.data { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; }
.big { font-size: 26px; font-weight: 900; margin: 7px 0; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.qr-panel {
margin-top: 14px;
display: grid;
grid-template-columns: 176px minmax(0, 1fr);
gap: 16px;
align-items: center;
}
.qr-panel img {
width: 176px;
height: 176px;
border: 1px solid var(--line);
border-radius: 14px;
padding: 8px;
background: #fff;
}
.countdown { color: var(--text); font-weight: 900; }
.tablewrap { overflow-x: auto; }
.toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.toolbar select {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
background: #fff;
color: var(--text);
font-weight: 650;
min-height: 40px;
}
.module-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.gitops-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.gitops-grid .mini { min-height: 118px; }
.gitops-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.module {
border: 1px solid #edf0f7;
background: #fff;
border-radius: 16px;
padding: 14px;
min-width: 0;
}
.module h4 { margin: 0 0 6px; font-size: 15px; }
.module p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
.ops-shell { display: grid; gap: 14px; }
.ops-head {
display: flex;
justify-content: space-between;
gap: 14px;
align-items: flex-start;
background: rgba(255,255,255,.95);
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
box-shadow: 0 10px 28px rgba(15,23,42,.05);
}
.ops-head h3 { margin: 0; font-size: 19px; }
.ops-head p { margin: 5px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
.ops-layout { display: grid; grid-template-columns: 1fr; gap: 14px; align-items: start; }
.ops-stack { display: grid; gap: 14px; }
.ops-panel {
background: #fff;
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
min-width: 0;
box-shadow: 0 8px 22px rgba(15,23,42,.04);
}
.ops-panel h4 { margin: 0 0 6px; font-size: 15px; }
.ops-panel p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
.ops-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
.ops-field { display: grid; gap: 5px; min-width: 0; }
.ops-field label { color: var(--muted); font-size: 12px; font-weight: 700; }
.ops-field input, .ops-field select {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--line);
border-radius: 8px;
padding: 9px 10px;
min-height: 38px;
background: #fff;
color: var(--text);
font-size: 13px;
}
.ops-field.full { grid-column: 1 / -1; }
.ops-table { width: 100%; min-width: 760px; }
.ops-table th, .ops-table td { vertical-align: top; }
.ops-actions { display: flex; gap: 7px; flex-wrap: wrap; align-items: center; }
.inline-json-form { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 0; }
.inline-json-form input {
width: 76px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 7px 8px;
min-height: 32px;
font-size: 12px;
}
.json-render {
margin-top: 10px;
border: 1px dashed #c9d9ff;
border-radius: 8px;
background: #f8fbff;
padding: 10px;
color: #234;
font-size: 12px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.json-render[hidden] { display: none; }
.ops-chipline { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.group-feature-menu {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
.group-feature-button {
width: 100%;
justify-content: flex-start;
min-height: 54px;
border-radius: 8px;
text-align: left;
}
.group-feature-button[aria-pressed="true"] {
border-color: #c9d9ff;
background: #eaf1ff;
color: var(--blue);
}
[data-group-feature-panel][hidden] { display: none; }
.governance-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.suggestion-grid, .policy-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.suggestion-item {
border: 1px solid #edf0f7;
background: #fff;
border-radius: 8px;
padding: 14px;
min-width: 0;
}
.suggestion-item b { display: block; margin-bottom: 6px; }
.suggestion-item p { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
[data-role-hidden="1"] { display: none !important; }
.button-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.button-row .btn { min-height: 34px; padding: 7px 10px; font-size: 12px; }
.compact-table td, .compact-table th { padding: 9px 8px; }
.detail-panel {
margin-top: 12px;
border: 1px solid #dbeafe;
background: #f8fbff;
border-radius: 8px;
padding: 14px;
}
.detail-panel[hidden] { display: none; }
.action-result {
margin-top: 14px;
padding: 12px 14px;
border: 1px solid #dbeafe;
background: #f8fbff;
border-radius: 14px;
color: #174ea6;
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.action-result[hidden] { display: none; }
@media (max-width: 1150px) {
.app { grid-template-columns: 1fr; }
.side { height: auto; position: relative; border-right: 0; border-bottom: 1px solid var(--line); }
.side-actions { margin-top: 18px; }
.nav { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid { grid-template-columns: 1fr; }
.ops-layout, .governance-grid { grid-template-columns: 1fr 1fr; }
.group-feature-menu { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.main { padding: 18px; }
.top, .user, .item, .qr-panel { grid-template-columns: 1fr; flex-direction: column; align-items: flex-start; }
.metrics, .data, .two, .module-grid, .gitops-grid, .suggestion-grid, .policy-grid { grid-template-columns: 1fr; }
.ops-head, .ops-layout, .ops-form-grid, .governance-grid, .group-feature-menu { grid-template-columns: 1fr; flex-direction: column; }
.ops-table { min-width: 680px; }
.nav { grid-template-columns: 1fr 1fr; }
.actions { justify-content: flex-start; }
}
</style>
{% if profile %}
<div class="app">
<aside class="side">
<div class="logo">
<div class="mark">个</div>
<div>
<h1>{% if profile %}{{ profile.user_id }}的工作台{% else %}用户工作台{% endif %}</h1>
<p>账号与工作空间</p>
</div>
</div>
<nav class="nav" aria-label="工作台导航">
<button type="button" class="active" data-sec="overview" data-role-only="user admin owner">概览</button>
<button type="button" data-sec="gitops" data-role-only="admin owner">GitOps</button>
<button type="button" data-sec="agents" data-role-only="user admin owner">智能体</button>
<button type="button" data-sec="usage" data-role-only="user admin owner">用量</button>
<button type="button" data-sec="channels" data-role-only="user admin owner">渠道</button>
<button type="button" data-sec="data" data-role-only="user admin owner">数据</button>
<button type="button" data-sec="suggestions" data-role-only="user admin owner">智能建议</button>
<button type="button" data-sec="notification-policy" data-role-only="user admin owner">通知策略</button>
{% if is_group_admin %}
<button type="button" data-sec="group-admin" data-role-only="admin owner">组管理</button>
{% endif %}
{% if is_owner %}
<button type="button" data-sec="owner-governance" data-role-only="owner">系统治理</button>
{% endif %}
</nav>
<div class="note">
<small>当前工作空间</small>
<b>{{ profile.active_group|default:profile.initial_group|default:"未分配" }}</b>
<span class="tag blue">工作空间</span>
</div>
<div class="side-actions">
<div class="role-switch">
<label for="permission-preview">当前用户权限</label>
<select id="permission-preview" aria-label="当前用户权限预览">
<option value="user" {% if not is_group_admin and not is_owner %}selected{% endif %}>用户</option>
{% if is_group_admin or is_owner %}
<option value="admin" {% if is_group_admin and not is_owner %}selected{% endif %}>管理员</option>
{% endif %}
{% if is_owner %}
<option value="owner" selected>owner</option>
{% endif %}
</select>
</div>
<div class="actions">
<a class="btn" href="/admin/password_change/">修改密码</a>
<form method="post" action="/admin/logout/">
{% csrf_token %}
<button type="submit" class="btn danger">注销</button>
</form>
</div>
</div>
</aside>
<main class="main">
{% for message in messages %}<div class="msg">{{ message }}</div>{% endfor %}
<header class="top">
<div class="user">
<div class="avatar">{{ profile.display_name|default:profile.user_id|slice:":1" }}</div>
<div>
<h2>{{ profile.user_id }}的工作台</h2>
</div>
</div>
</header>
<section id="overview" class="section active">
<section class="metrics" aria-label="工作台摘要">
<div class="metric" role="button" tabindex="0" data-detail-target="detail-agents"><small>智能体数量</small><strong>4</strong><span>个人 2 / 组共享 2</span></div>
<div class="metric" role="button" tabindex="0" data-detail-target="detail-token"><small>本月 Token</small><strong>{{ profile_quota.monthly_request_limit|default:"-" }}</strong><span>月额度上限</span></div>
<div class="metric" role="button" tabindex="0" data-detail-target="detail-daily"><small>日请求额度</small><strong>{{ profile_quota.daily_request_limit|default:"-" }}</strong><span>按当前权限组合并</span></div>
<div class="metric" role="button" tabindex="0" data-detail-target="detail-sessions"><small>活跃会话</small><strong>{{ bindings|length }}</strong><span>可见渠道会话</span></div>
<div class="metric" role="button" tabindex="0" data-detail-target="detail-channels"><small>渠道管理</small><strong>{{ wechat_bound_count|default:0 }}</strong><span>微信 / Web / Hermes / OpenClaw / API</span></div>
<div class="metric" role="button" tabindex="0" data-detail-target="detail-tasks"><small>运行中任务</small><strong>0</strong><span>需审批任务会在此提示</span></div>
</section>
<section class="detail-panel" id="detail-agents" hidden><b>智能体数量详情</b><p>默认助手:work-agent;个人助手 2 个,组共享助手 2 个。当前配置会按用户、管理员和 owner 权限分别过滤。</p></section>
<section class="detail-panel" id="detail-token" hidden><b>本月 Token 详情</b><p>月请求额度:{{ profile_quota.monthly_request_limit|default:"-" }};已用额度按网关审计聚合;剩余额度按当前额度策略扣减;异常消耗会进入审批摘要。</p></section>
<section class="detail-panel" id="detail-daily" hidden><b>日请求额度详情</b><p>每日请求限制:{{ profile_quota.daily_request_limit|default:"-" }};已用请求由当天审计记录计算;审批阈值按所属组策略与用户策略合并。</p></section>
<section class="detail-panel" id="detail-sessions" hidden><b>活跃会话详情</b><p>可见渠道会话:{{ bindings|length }}。Web 会话、微信会话、Hermes 会话和 OpenClaw 会话只展示状态与数量,不展示私密正文。</p></section>
<section class="detail-panel" id="detail-channels" hidden><b>渠道管理详情</b><p>微信绑定 {{ wechat_bound_count|default:0 }} 个;Web、Hermes、OpenClaw 和 API 渠道按当前用户可见范围显示绑定状态、最近活动和异常摘要。</p></section>
<section class="detail-panel" id="detail-tasks" hidden><b>运行中任务详情</b><p>当前无运行中任务;需审批任务会在此提示,并展示任务数量、审批状态和最近异常摘要。</p></section>
<div class="grid">
<div class="card">
<div class="title">
<div>
<h3>Token 累计消耗趋势</h3>
<p>按用户聚合,可切换工作空间查看组内归属。</p>
</div>
<span class="tag blue">近 30 天</span>
</div>
<div class="chart" data-usage-trend>
<svg viewBox="0 0 900 260" preserveAspectRatio="none" aria-hidden="true">
<defs><linearGradient id="area" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="#2563eb" stop-opacity=".24"/><stop offset="100%" stop-color="#2563eb" stop-opacity="0"/></linearGradient></defs>
<g stroke="#e5e7eb"><line x1="40" y1="45" x2="860" y2="45"/><line x1="40" y1="95" x2="860" y2="95"/><line x1="40" y1="145" x2="860" y2="145"/><line x1="40" y1="195" x2="860" y2="195"/><line x1="40" y1="230" x2="860" y2="230"/></g>
<path d="M40 212 C120 204,160 190,210 184 C280 173,305 151,365 154 C430 158,455 116,520 121 C590 126,625 86,688 92 C750 96,790 70,860 54 L860 230 L40 230 Z" fill="url(#area)"/>
<path d="M40 212 C120 204,160 190,210 184 C280 173,305 151,365 154 C430 158,455 116,520 121 C590 126,625 86,688 92 C750 96,790 70,860 54" fill="none" stroke="#2563eb" stroke-width="5" stroke-linecap="round"/>
<path d="M40 226 C120 222,180 216,240 204 C330 188,390 180,455 160 C535 136,585 130,650 105 C725 80,790 68,860 42" fill="none" stroke="#7c3aed" stroke-width="3" stroke-dasharray="8 9"/>
</svg>
<div class="chart-days" aria-label="按天横轴">
{% for point in usage_trend_days %}
<span data-trend-day="{{ point.iso }}" title="{{ point.token_total }} Token">{{ point.label }}</span>
{% endfor %}
</div>
</div>
<div class="legend"><span><i class="dot" style="background:#2563eb"></i>总 Token</span><span><i class="dot" style="background:#7c3aed"></i>输出 Token</span><span><i class="dot" style="background:#16a34a"></i>工具 / 上下文 Token</span></div>
</div>
<div class="stack">
<div class="card">
<div class="title"><div><h3>额度使用</h3><p>多组用户:数值额度取最大值。</p></div><span class="tag green">正常</span></div>
<div class="mini">
<b>月请求额度 {{ profile_quota.monthly_request_limit|default:"-" }}</b>
<p>当前额度来自所属用户组与邀请码策略。若权限被显式 deny,deny 优先。</p>
<div class="bar"><i></i></div>
</div>
</div>
<div class="card">
<div class="title"><div><h3>提醒</h3><p>与当前账号有关的事项。</p></div></div>
<div class="stack">
<div class="mini"><b>微信渠道:{{ wechat_state_label|default:"未绑定" }}</b><p>{{ wechat_state_detail|default:"绑定后可接收系统通知和任务消息。" }}</p></div>
<div class="mini"><b>使用边界</b><p>当前页面只展示你可以使用的服务,不展示底层配置、密钥或敏感路由。</p></div>
</div>
</div>
</div>
</div>
</section>
<section id="gitops" class="section" data-role-only="admin owner">
<div class="card">
<div class="title">
<div>
<h3>GitOps 在线基线</h3>
<p>版本浏览、diff、patch、模型策略和自定义 GPT Actions 都从这里进入。短链接已改为登录后访问。</p>
</div>
<span class="tag blue">{{ gitops.mode }}</span>
</div>
<div class="gitops-grid">
<div class="mini"><b>当前版本</b><div class="big">{{ gitops.active_version }}</div><p>Tag:{{ gitops.active_tag }}<br>Commit:{{ gitops.git|default:"-" }}</p></div>
<div class="mini"><b>在线 Git</b><p>Gitea 已要求登录访问,默认账号登录已禁用,owner 密码由用户中心 owner 自动同步。</p><div class="gitops-actions"><a class="btn small" href="{{ gitops.git_url }}">打开仓库</a></div></div>
<div class="mini"><b>Actions API</b><p>ChatGPT / Codex 写操作继续使用 Bearer Token;人看的短链接不再公开 JSON。</p><div class="gitops-actions"><a class="btn small" href="{{ gitops.schema_url }}">OpenAPI</a></div></div>
<div class="mini"><b>自定义 GPT</b><p>AIOS GitOps 控制台可通过同一 API 提交 patch、文件和版本请求。</p><div class="gitops-actions"><a class="btn small" href="{{ gitops.custom_gpt_url }}">打开 GPT</a></div></div>
</div>
<div class="module-grid" style="margin-top:12px">
<div class="module"><h4>访问策略</h4><p>浏览入口需要登录;`/api/*` 保留机器接口,写接口必须带 Bearer Token 并落 Git commit。</p></div>
<div class="module"><h4>最新刷新</h4><p>{{ gitops.updated_at|default:"等待 baseline 更新" }}。发布新版本后此处会读取 `/api/base` 自动刷新。</p></div>
</div>
</div>
</section>
<section id="agents" class="section">
<div class="card">
<div class="title"><div><h3>我的智能体</h3><p>每个智能体独立 runtime、workspace 和操作记录。</p></div><button type="button" class="btn primary" data-agent-create-toggle aria-controls="agent-create-panel" aria-expanded="false">创建我的智能体</button></div>
<div id="agent-create-panel" class="ops-panel" hidden>
<h4>创建我的智能体</h4>
<p>提交后进入审批队列,审批通过后会分配 runtime、workspace 和默认通信渠道。</p>
<div class="ops-form-grid">
<div class="ops-field"><label>名称</label><input name="agent_name" value="个人助手"></div>
<div class="ops-field"><label>模式</label><select name="agent_mode"><option>快速模式</option><option>深度模式</option><option>代码模式</option><option>运维诊断</option></select></div>
<div class="ops-field full"><label>用途</label><input name="agent_reason" placeholder="例如:日常问答、代码审查、资料整理"></div>
</div>
<div class="button-row"><button type="button" class="btn primary" data-agent-create-submit>提交创建申请</button><button type="button" class="btn" data-agent-create-toggle>收起</button></div>
</div>
<div id="agent-action-result" class="action-result" hidden></div>
<div class="tablewrap">
<table>
<thead><tr><th>智能体</th><th>作用范围</th><th>模式</th><th>会话</th><th>Token</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
<tr><td><div class="agent"><span class="icon">日</span><b>日常助手</b></div></td><td>个人</td><td>快速模式</td><td>0</td><td>-</td><td><span class="tag green">可用</span></td><td><div class="ops-actions"><span class="tag blue">默认</span><button type="button" class="btn" data-agent-action="日常助手配置管理">配置管理</button><button type="button" class="btn" data-agent-action="日常助手运行记录">查看运行记录</button></div></td></tr>
<tr><td><div class="agent"><span class="icon" style="background:#7c3aed">架</span><b>架构设计助手</b></div></td><td>组</td><td>深度模式</td><td>0</td><td>-</td><td><span class="tag green">可用</span></td><td><div class="ops-actions"><button type="button" class="btn" data-agent-action="架构设计助手配置管理">配置管理</button><button type="button" class="btn" data-agent-action="架构设计助手运行记录">查看运行记录</button>{% if selected_admin_group %}<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/agents/work-agent/set-default" data-action-method="POST" data-render-target="agent-action-result">设为默认</button>{% else %}<button type="button" class="btn" data-agent-action="架构设计助手设为默认申请">设为默认</button>{% endif %}</div></td></tr>
<tr><td><div class="agent"><span class="icon" style="background:#111827">码</span><b>编码助手</b></div></td><td>个人</td><td>代码模式</td><td>0</td><td>-</td><td><span class="tag green">可用</span></td><td><div class="ops-actions"><button type="button" class="btn" data-agent-action="编码助手配置管理">配置管理</button><button type="button" class="btn" data-agent-action="编码助手运行记录">查看运行记录</button><button type="button" class="btn" data-agent-action="编码助手设为默认申请">设为默认</button></div></td></tr>
<tr><td><div class="agent"><span class="icon" style="background:#d97706">运</span><b>运维诊断助手</b></div></td><td>管理组</td><td>运维诊断</td><td>0</td><td>-</td><td><span class="tag orange">需审批</span></td><td><div class="ops-actions"><button type="button" class="btn" data-agent-action="运维诊断助手配置管理">配置管理</button><button type="button" class="btn" data-agent-action="运维诊断助手运行记录">查看运行记录</button>{% if selected_admin_group %}<button type="button" class="btn primary" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/agents/ops-agent/enable" data-action-method="POST" data-render-target="agent-action-result">提交审批</button>{% else %}<button type="button" class="btn primary" data-agent-action="运维诊断助手开通审批">提交审批</button>{% endif %}</div></td></tr>
</tbody>
</table>
</div>
</div>
</section>
<section id="usage" class="section">
<div class="two">
<div class="card">
<h3>按智能体 Token 分布</h3>
<div class="chart"><svg viewBox="0 0 520 260" aria-hidden="true"><circle cx="145" cy="130" r="82" fill="none" stroke="#e5e7eb" stroke-width="36"/><circle cx="145" cy="130" r="82" fill="none" stroke="#2563eb" stroke-width="36" stroke-dasharray="190 520" stroke-linecap="round" transform="rotate(-90 145 130)"/><circle cx="145" cy="130" r="82" fill="none" stroke="#7c3aed" stroke-width="36" stroke-dasharray="120 520" stroke-dashoffset="-190" stroke-linecap="round" transform="rotate(-90 145 130)"/><text x="145" y="123" text-anchor="middle" font-size="28" font-weight="800">-</text><text x="145" y="148" text-anchor="middle" font-size="12" fill="#6b7280">本月 Token</text><text x="300" y="92" font-size="14">编码助手</text><text x="300" y="132" font-size="14">架构助手</text><text x="300" y="172" font-size="14">日常助手</text></svg></div>
</div>
<div class="card">
<h3>用量建议</h3>
<div class="stack" style="margin-top:14px">
<div class="mini"><b>上下文复用</b><p>长任务建议使用同一智能体与同一渠道,减少重复上下文成本。</p></div>
<div class="mini"><b>额度提醒</b><p>超出组策略前,页面和渠道消息会提前提示。</p></div>
</div>
</div>
</div>
</section>
<section id="channels" class="section">
<div id="channel-management" class="card">
<div class="title">
<div>
<h3>渠道管理</h3>
<p>统一管理微信、Web、Hermes、OpenClaw、API 等入口。绑定和解绑只影响当前用户身份,不影响其他用户或组。</p>
<div class="channel-status-summary">
<span class="tag blue">当前账号:{{ profile.user_id }}{% if profile.display_name %} / {{ profile.display_name }}{% endif %}</span>
{% if wechat_bound_count|default:0 %}
<span class="tag green">个人微信已绑定 {{ wechat_bound_count }} 个</span>
{% else %}
<span class="tag orange">个人微信未绑定</span>
{% endif %}
{% if wechat_pending_binding %}<span class="tag orange">有待确认二维码</span>{% endif %}
</div>
</div>
{% if wechat_can_generate %}
<form method="post" action="{% url 'portal_bind_wechat' %}" data-wechat-bind-form>
{% csrf_token %}
<button type="submit" class="btn primary">绑定个人微信</button>
</form>
{% endif %}
</div>
<div id="wechat-bind-result" class="action-result" hidden></div>
<div class="list">
{% if wechat_bound_bindings %}
{% for item in wechat_bound_bindings %}
<div class="item" id="wechat-binding-{{ item.id }}" {% if item.is_primary %}data-primary-binding="{{ item.id }}"{% endif %}>
<div class="left">
<span class="glyph">微</span>
<div>
<div class="channel-title-row">
<b id="wechat-channel-name-{{ item.id }}">{% if item.remark %}{{ item.remark }}{% else %}个人微信{% endif %}</b>
<button type="button" class="name-edit-button" data-remark-edit="{{ item.id }}" aria-controls="wechat-remark-form-{{ item.id }}" aria-expanded="false">编辑备注</button>
</div>
<form id="wechat-remark-form-{{ item.id }}" class="remark-inline-form" method="post" action="{% url 'portal_update_wechat_remark' item.id %}" data-remark-form="{{ item.id }}" hidden>
{% csrf_token %}
<label for="wechat-remark-{{ item.id }}">备注</label>
<input id="wechat-remark-{{ item.id }}" type="text" name="remark" value="{{ item.remark }}" placeholder="例如:家里微信">
<button type="submit" class="btn small">保存</button>
<button type="button" class="btn small ghost" data-remark-cancel="{{ item.id }}">取消</button>
</form>
<div class="muted">Chat ID:{{ item.external_chat_id }} / 渠道 ID:{{ item.external_channel_id|default:"openclaw-weixin" }} / 最近更新:{{ item.updated_at }} / 共享同一账号记忆</div>
<div class="channel-status-summary">
<span class="tag green">已绑定</span>
{% if item.is_primary %}<span class="tag green">主通信渠道</span>{% else %}<span class="tag">备用通信渠道</span>{% endif %}
</div>
</div>
</div>
<div class="actions channel-actions">
<form method="post" action="{% url 'portal_set_primary_wechat' item.id %}">
{% csrf_token %}
<button type="submit" class="btn">设置为主通信渠道</button>
</form>
<form method="post" action="{% url 'portal_unbind_wechat' item.id %}" data-unbind-form data-binding-label="{% if item.remark %}{{ item.remark }}{% else %}个人微信{% endif %}">
{% csrf_token %}
<input type="hidden" name="delete_memory" value="no">
<button type="submit" class="btn danger">解除绑定</button>
</form>
</div>
</div>
{% endfor %}
{% endif %}
{% if not wechat_bound_bindings %}
<div class="item">
<div class="left">
<span class="glyph">微</span>
<div>
<b>个人微信</b>
<div class="muted">
{{ wechat_state_label|default:"未绑定" }}
{% if wechat_bound_binding %} / {{ wechat_bound_binding.external_chat_id }} / 最近更新:{{ wechat_bound_binding.updated_at }} / 主通信渠道{% endif %}
{% if wechat_pending_binding %} / 二维码会话:{{ wechat_pending_binding.qr_session_id }}{% endif %}
</div>
</div>
</div>
<div class="actions">
{% if wechat_pending_binding %}
<form method="post" action="{% url 'portal_unbind_wechat' wechat_pending_binding.id %}">
{% csrf_token %}
<button type="submit" class="btn">取消二维码</button>
</form>
{% elif wechat_can_generate %}
<form method="post" action="{% url 'portal_bind_wechat' %}" data-wechat-bind-form>
{% csrf_token %}
<button type="submit" class="btn primary">生成绑定二维码</button>
</form>
{% else %}
<span class="tag orange">微信二维码暂不可用</span>
{% endif %}
</div>
</div>
{% endif %}
{% if wechat_pending_binding and wechat_plugin_qr_available %}
<div id="wechat-qr-panel" class="mini" data-status-url="{% url 'portal_wechat_binding_status' wechat_pending_binding.id %}" data-account-url="/portal/me/">
<div class="qr-panel">
<img id="wechat-qr-image" src="{% url 'portal_wechat_qr_image' wechat_pending_binding.id %}" alt="微信绑定二维码">
<div>
<b id="wechat-qr-status">等待扫码确认。</b>
<p>请用本人微信扫码。扫码确认后,本页会自动更新绑定结果。</p>
<p>剩余有效时间:<strong id="wechat-qr-countdown" class="countdown" data-remaining-seconds="{{ wechat_remaining_seconds|default:0 }}">{{ wechat_remaining_seconds|default:0 }} 秒</strong></p>
</div>
</div>
</div>
{% endif %}
<div class="item"><div class="left"><span class="glyph">W</span><div><b>Web</b><div class="muted">已启用 / 当前浏览器会话 / 登录入口</div></div></div><span class="tag green">当前在线</span></div>
<div class="item"><div class="left"><span class="glyph">本</span><div><b>本机工具</b><div class="muted">用于在当前电脑继续处理个人任务。</div></div></div><span class="tag blue">本机可用</span></div>
<div class="item"><div class="left"><span class="glyph">H</span><div><b>Hermes</b><div class="muted">用于长任务和多助手执行,当前账号默认可用。</div></div></div><span class="tag green">默认开通</span></div>
<div class="item"><div class="left"><span class="glyph">O</span><div><b>OpenClaw</b><div class="muted">用于任务通知和后台执行,当前账号默认可用。</div></div></div><span class="tag green">默认开通</span></div>
<div class="item"><div class="left"><span class="glyph">A</span><div><b>API Access</b><div class="muted">适合自动化和外部系统调用,需审批启用。</div></div></div><span class="tag orange">需审批</span></div>
</div>
<div class="mini" style="margin-top:14px">
<b>渠道隔离说明</b>
<p>渠道只是入口。无论从微信、Web、本机工具还是 Hermes 进入,都会映射到同一个用户身份,并按当前工作空间做数据隔离、计费和审计。</p>
</div>
</div>
</section>
<section id="data" class="section">
<div class="card">
<div class="title"><div><h3>我的数据资产</h3><p>仅展示个人和当前工作空间授权数据。</p></div></div>
<div class="data">
<div class="box" role="button" tabindex="0" data-detail-target="detail-personal-memory"><span class="muted">个人记忆</span><div class="big">隔离</div><span class="tag green">user_id</span></div>
<div class="box" role="button" tabindex="0" data-detail-target="detail-group-memory"><span class="muted">组共享记忆</span><div class="big">{{ profile_groups|length }}</div><span class="tag blue">授权组</span></div>
<div class="box" role="button" tabindex="0" data-detail-target="detail-assets"><span class="muted">文件空间</span><div class="big">-</div><span class="tag">待统计</span></div>
<div class="box" role="button" tabindex="0" data-detail-target="detail-knowledge"><span class="muted">知识库</span><div class="big">-</div><span class="tag purple">按组可见</span></div>
</div>
<section class="detail-panel" id="detail-personal-memory" hidden><b>个人记忆详情</b><p>仅展示隔离状态和索引统计,不展示个人记忆正文。</p></section>
<section class="detail-panel" id="detail-group-memory" hidden><b>组共享记忆详情</b><p>展示当前工作空间授权的共享记忆数量和更新时间。</p></section>
<section class="detail-panel" id="detail-assets" hidden><b>文件空间详情</b><p>展示文件数量、容量、最近上传时间和组共享边界,不展示文件正文。</p></section>
<section class="detail-panel" id="detail-knowledge" hidden><b>知识库详情</b><p>展示可见知识库、写入权限和最近同步状态。</p></section>
</div>
</section>
<section id="suggestions" class="section" data-role-only="user admin owner">
<div class="card">
<div class="title">
<div>
<h3>智能建议中心</h3>
<p>根据你的账号、工作空间、渠道和近期操作给出可执行建议。</p>
</div>
<span class="tag blue">低频提醒</span>
</div>
<div class="suggestion-grid">
<div class="suggestion-item">
<b>个人助手建议</b>
<p>优先使用默认助手处理日常问答;长任务建议保持同一渠道,减少重复上下文。</p>
<div class="button-row"><button type="button" class="btn" data-jump="agents">查看助手</button><button type="button" class="btn primary" data-jump="agents">提交审批</button></div>
</div>
<div class="suggestion-item">
<b>主通信渠道建议</b>
<p>{% if wechat_bound_count|default:0 %}建议保留一个微信为主通信渠道,用于接收审批与任务完成提醒。{% else %}建议绑定一个个人微信,并设置为主通信渠道。{% endif %}</p>
<div class="button-row"><button type="button" class="btn" data-jump="channels">管理渠道</button><button type="button" class="btn primary" data-jump="notification-policy">提交审批</button></div>
</div>
<div class="suggestion-item">
<b>额度使用建议</b>
<p>本月额度、日请求额度和异常消耗会聚合展示;涉及提额或高风险操作时进入审批。</p>
<div class="button-row"><button type="button" class="btn" data-jump="usage">查看用量</button>{% if is_group_admin and selected_admin_group %}<button type="button" class="btn primary" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/approvals" data-action-method="GET" data-render-target="suggestion-action-result">查看审批队列</button>{% else %}<button type="button" class="btn primary" data-jump="group-admin">提交审批</button>{% endif %}</div>
</div>
{% if is_group_admin %}
<div class="suggestion-item" data-role-only="admin owner">
<b>组管理建议</b>
<p>先处理待审批事项,再调整成员权限或额度;所有变更保留审计和回滚入口。</p>
<div class="button-row"><button type="button" class="btn" data-jump="group-admin">进入组管理</button><button type="button" class="btn primary" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/approvals" data-action-method="GET" data-render-target="suggestion-action-result">查看审批队列</button></div>
</div>
{% endif %}
{% if is_owner %}
<div class="suggestion-item" data-role-only="owner">
<b>系统治理建议</b>
<p>Owner 只处理跨组策略、高风险审批和全局审计,不进入成员日常操作细节。</p>
<div class="button-row"><button type="button" class="btn" data-jump="owner-governance">查看治理</button><button type="button" class="btn primary" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/approvals" data-action-method="GET" data-render-target="suggestion-action-result">查看审批队列</button></div>
</div>
{% endif %}
</div>
<div id="suggestion-action-result" class="json-render" hidden></div>
</div>
</section>
<section id="notification-policy" class="section" data-role-only="user admin owner">
<div class="card">
<div class="title">
<div>
<h3>主通信渠道策略</h3>
<p>通知只发到主通信渠道;非紧急事项合并发送,避免打扰。</p>
</div>
<span class="tag green">默认开启</span>
</div>
<div class="policy-grid">
<div class="suggestion-item"><b>个人通知</b><p>每日最多 1 次摘要;任务失败、绑定异常和审批结果可即时发送。</p><div class="button-row"><button type="button" class="btn primary" data-jump="channels">提交审批</button></div></div>
<div class="suggestion-item"><b>组管理通知</b><p>管理员的成员、邀请码、额度和权限变更按 4 小时窗口合并。</p><div class="button-row">{% if is_group_admin and selected_admin_group %}<button type="button" class="btn primary" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/approvals" data-action-method="GET" data-render-target="notification-policy-result">查看审批队列</button>{% else %}<button type="button" class="btn primary" data-jump="group-admin">提交审批</button>{% endif %}</div></div>
<div class="suggestion-item"><b>Owner 通知</b><p>跨组高风险审批即时提醒;普通审计摘要每 30 分钟合并。</p><div class="button-row"><button type="button" class="btn primary" data-jump="{% if is_owner %}owner-governance{% else %}group-admin{% endif %}">提交审批</button></div></div>
<div class="suggestion-item"><b>静默时段</b><p>22:30 - 08:30 默认只发送阻塞类和安全类提醒。</p><div class="button-row"><button type="button" class="btn primary" data-jump="group-admin">提交审批</button></div></div>
<div class="suggestion-item"><b>失败兜底</b><p>主通信渠道不可达时,页面显示待处理事项,不切换到未验证渠道。</p><div class="button-row"><button type="button" class="btn primary" data-jump="channels">提交审批</button></div></div>
<div class="suggestion-item"><b>可见边界</b><p>通知只包含业务摘要、下一步动作和审批入口,不包含密钥、内部路径或服务端细节。</p><div class="button-row"><button type="button" class="btn primary" data-jump="group-admin">提交审批</button></div></div>
</div>
<div id="notification-policy-result" class="json-render" hidden></div>
</div>
</section>
{% if is_owner %}
<section id="owner-governance" class="section" data-role-only="owner">
<div class="ops-shell">
<div class="ops-head">
<div>
<h3>系统治理</h3>
<p>Owner 视图集中处理全局策略、跨组审批、回滚监督和证据归档。</p>
</div>
<span class="tag red">高风险审批</span>
</div>
{% if selected_admin_group %}
<div class="governance-grid">
<div class="ops-panel"><h4>全局策略</h4><p>查看策略、额度层级和默认配置变更记录。</p><div class="button-row"><button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/audit" data-action-method="GET" data-render-target="owner-governance-result">查看审计</button></div></div>
<div class="ops-panel"><h4>跨组审批</h4><p>处理跨组影响或高风险申请,普通组内操作由管理员处理。</p><div class="button-row"><button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/approvals" data-action-method="GET" data-render-target="owner-governance-result">查看审批队列</button></div></div>
<div class="ops-panel"><h4>回滚监督</h4><p>查看最近变更、回滚状态和补偿动作。</p><div class="button-row"><button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/operations" data-action-method="GET" data-render-target="owner-governance-result">查看操作记录</button></div></div>
<div class="ops-panel"><h4>证据归档</h4><p>导出审计记录、通知记录和策略变更证据。</p><div class="button-row"><button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/audit" data-action-method="GET" data-render-target="owner-governance-result">导出记录</button></div></div>
</div>
<div id="owner-governance-result" class="json-render" hidden></div>
{% else %}
<div class="ops-panel"><p>当前没有可治理的管理组。</p></div>
{% endif %}
</div>
</section>
{% endif %}
{% if is_group_admin %}
<section id="group-admin" class="section" data-role-only="admin owner">
<div class="ops-shell">
<div class="ops-head">
<div>
<h3>组管理</h3>
<p>成员、微信绑定、邀请码、组用量、审批、策略和操作记录集中在这里处理。</p>
</div>
<span class="tag blue">{{ selected_admin_group.name|default:"管理组" }}</span>
</div>
<div class="toolbar">
<form method="get" action="/portal/me/">
<label class="muted" for="admin-group-select">当前管理组</label>
<select id="admin-group-select" name="admin_group" onchange="this.form.submit()">
{% for group in admin_groups %}
<option value="{{ group.id }}" {% if selected_admin_group and group.id == selected_admin_group.id %}selected{% endif %}>{{ group.name }} / {{ group.code }}</option>
{% endfor %}
</select>
</form>
<div class="actions">
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/runbook/generate" data-action-method="POST" data-render-target="group-action-json">生成 Runbook</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/audit" data-action-method="GET" data-render-target="group-action-json">导出记录</button>
</div>
</div>
<div id="group-action-result" class="action-result" hidden></div>
<div id="group-action-json" class="json-render" hidden></div>
<div class="data">
<div class="box" role="button" tabindex="0" data-group-feature-target="members" aria-pressed="false"><span class="muted">成员</span><div class="big">{{ group_member_count|default:0 }}</div><span class="tag green">active {{ group_active_member_count|default:0 }}</span></div>
<div class="box" role="button" tabindex="0" data-group-feature-target="channels" aria-pressed="false"><span class="muted">微信绑定</span><div class="big">{{ group_wechat_bound_count|default:0 }}</div><span class="tag blue">个人渠道</span></div>
<div class="box" role="button" tabindex="0" data-group-feature-target="usage" aria-pressed="false"><span class="muted">组用量</span><div class="big">{{ group_token_total|default:0 }}</div><span class="tag purple">累计 Token</span></div>
<div class="box" role="button" tabindex="0" data-group-feature-target="invite-list" aria-pressed="false"><span class="muted">查看邀请码</span><div class="big">{{ group_invite_count|default:0 }}</div><span class="tag orange">可管理</span></div>
<div class="box" role="button" tabindex="0" data-group-feature-target="invite-create" aria-pressed="false"><span class="muted">创建邀请码</span><div class="big">+</div><span class="tag orange">新成员</span></div>
</div>
<div class="ops-layout">
<div class="ops-stack">
<div class="ops-panel" data-group-feature-panel="invite-create" hidden>
<h4>创建邀请码</h4>
<p>默认自动生成邀请码;也可以填写自定义邀请码,提交前会检查重复。管理员只能创建“用户”邀请码,owner 可创建“管理员”邀请码。</p>
<form data-json-form="invite-create" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/invites" data-action-method="POST">
{% csrf_token %}
<div class="ops-form-grid">
<div class="ops-field full"><label>标题</label><input name="title" value="组成员邀请" maxlength="128"></div>
<div class="ops-field"><label>自定义邀请码</label><input name="custom_code" placeholder="留空自动生成" maxlength="64"></div>
<div class="ops-field"><label>目标组</label><select name="target_group_id">{% for group in available_invite_target_groups %}<option value="{{ group.id }}" {% if selected_admin_group and group.id == selected_admin_group.id %}selected{% endif %}>{{ group.name }}</option>{% endfor %}</select></div>
<div class="ops-field"><label>角色</label><select name="default_role">{% for role in allowed_invite_roles %}<option value="{{ role }}">{% if role == "group_admin" %}管理员{% else %}用户{% endif %}</option>{% endfor %}</select></div>
<div class="ops-field"><label>有效数量</label><input name="max_uses" type="number" min="1" value="1"></div>
<div class="ops-field"><label>有效期</label><input name="expires_at" type="datetime-local"></div>
<div class="ops-field"><label>默认权限组</label><select name="default_capability_group"><option value="">不指定</option>{% for group in available_capability_groups %}<option value="{{ group.id }}">{{ group.name }}</option>{% endfor %}</select></div>
<div class="ops-field"><label>默认额度</label><select name="default_quota_policy"><option value="">不指定</option>{% for policy in available_quota_policies %}<option value="{{ policy.id }}">{{ policy.name }}</option>{% endfor %}</select></div>
<div class="ops-field full"><label>备注</label><input name="remark" placeholder="用途或批次说明"></div>
</div>
<div class="button-row"><button type="submit" class="btn primary">创建邀请码</button></div>
</form>
<div id="invite-list-json" class="json-render" hidden></div>
</div>
<div class="ops-panel" data-group-feature-panel="policy" hidden>
<h4>权限与策略</h4>
<p>策略与治理动作直接提交并生成审计记录。</p>
<div class="button-row">
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/policy/default-capability-groups" data-action-method="PATCH" data-action-payload='{"capability_group_ids":[{{ available_capability_groups.0.id|default:"0" }}]}' data-render-target="group-action-json">修改默认权限</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/policy/approval-required" data-action-method="PATCH" data-action-payload='{"value":["l2_ops"]}' data-render-target="group-action-json">修改审批策略</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/policy/deny" data-action-method="PATCH" data-action-payload='{"value":["secret_plaintext_read"]}' data-render-target="group-action-json">修改禁止策略</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/policy/default-agent" data-action-method="PATCH" data-action-payload='{"value":"work-agent"}' data-render-target="group-action-json">修改默认助手</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/agents/work-agent/enable" data-action-method="POST" data-render-target="group-action-json">启用助手</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/skills/report-builder/enable" data-action-method="POST" data-render-target="group-action-json">启用技能</button>
<button type="button" class="btn danger" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/runtime/work-agent/kill-switch" data-action-method="POST" data-confirm="确定提交紧急停用审批请求吗?" data-render-target="group-action-json">紧急停用</button>
</div>
</div>
</div>
<div class="ops-stack">
<div class="ops-panel" data-group-feature-panel="invite-list" hidden>
<h4>查看邀请码</h4>
<p>邀请码管理显示邀请码、是否失效、创建时间/有效期时间、使用数量/有效数量,并支持延期、禁用、调整次数和查看使用记录。</p>
<div class="tablewrap">
<table class="compact-table ops-table">
<thead><tr><th>邀请码</th><th>是否失效</th><th>创建时间/有效期时间</th><th>使用数量/有效数量</th><th>默认角色</th><th>操作</th></tr></thead>
<tbody>
{% for row in group_invites %}
<tr>
<td><b>{{ row.full_code }}</b><div class="muted">{{ row.invite.title|default:"未命名" }}</div></td>
<td>{% if row.is_invalid %}<span class="tag red">已失效</span>{% else %}<span class="tag green">可用</span>{% endif %}<div class="muted">{{ row.status_label }}</div></td>
<td>{{ row.created_label }}<div class="muted">有效期:{{ row.expires_label }}</div></td>
<td>{{ row.invite.used_count }} / {{ row.invite.max_uses }}<div class="muted">记录 {{ row.redemption_count }} 条</div></td>
<td>{{ row.role_label }}</td>
<td>
<div class="ops-actions">
<button type="button" class="btn" data-copy-text="{{ row.full_code }}" data-missing="暂无完整邀请码可复制">复制完整码</button>
<form class="inline-json-form" data-json-form="invite-extend-{{ row.invite.id }}" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/invites/{{ row.invite.id }}/extend" data-action-method="POST">
<input name="days" type="number" min="1" value="7" aria-label="延期天数"><button type="submit" class="btn">延期</button>
</form>
<form class="inline-json-form" data-json-form="invite-limit-{{ row.invite.id }}" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/invites/{{ row.invite.id }}/reset-usage-limit" data-action-method="POST">
<input name="max_uses" type="number" min="1" value="{{ row.invite.max_uses }}" aria-label="有效数量"><button type="submit" class="btn">调整次数</button>
</form>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/invites/{{ row.invite.id }}/redemptions" data-action-method="GET" data-render-target="invite-redemptions-{{ row.invite.id }}">查看使用记录</button>
<button type="button" class="btn danger" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/invites/{{ row.invite.id }}/disable" data-action-method="POST" data-confirm="确定禁用该邀请码吗?">禁用</button>
</div>
<div id="invite-redemptions-{{ row.invite.id }}" class="json-render" hidden></div>
</td>
</tr>
{% empty %}
<tr><td colspan="6">暂无邀请码,请先创建。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="ops-panel" data-group-feature-panel="members" hidden>
<h4>成员管理</h4>
<p>成员管理按成员逐行操作,可查看详情、调整角色、调整额度、调整权限、禁用、恢复、移出和发送绑定引导。</p>
<div class="tablewrap">
<table class="compact-table ops-table">
<thead><tr><th>成员</th><th>角色/状态</th><th>渠道/最近活动</th><th>操作</th></tr></thead>
<tbody>
{% for row in group_members %}
<tr>
<td><b>{{ row.profile.display_name }}</b><div class="muted">{{ row.profile.user_id }}</div></td>
<td>{{ row.role_label }}<div class="muted">{{ row.status_label }}</div></td>
<td>{{ row.channel_summary }}<div class="muted">{{ row.last_active_label }}</div></td>
<td>
<div class="ops-actions">
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/members/{{ row.profile.user_id }}" data-action-method="GET" data-render-target="member-detail-{{ row.profile.user_id }}">查看</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/members/{{ row.profile.user_id }}/role" data-action-method="PATCH" data-action-payload='{"role":"{% if row.role == "admin" %}user{% else %}group_admin{% endif %}"}'>调整角色</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/members/{{ row.profile.user_id }}/quota-policy" data-action-method="PATCH" data-action-payload='{"quota_policy_id":{{ available_quota_policies.0.id|default:"0" }}}'>调整额度</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/members/{{ row.profile.user_id }}/capability-groups" data-action-method="PATCH" data-action-payload='{"capability_group_ids":[{{ available_capability_groups.0.id|default:"0" }}]}'>调整权限</button>
<button type="button" class="btn danger" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/members/{{ row.profile.user_id }}/disable" data-action-method="POST">禁用</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/members/{{ row.profile.user_id }}/restore" data-action-method="POST">恢复</button>
<button type="button" class="btn danger" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/members/{{ row.profile.user_id }}/remove" data-action-method="POST" data-confirm="确定移出该成员吗?">移出</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/members/{{ row.profile.user_id }}/send-binding-guide" data-action-method="POST">发送绑定引导</button>
</div>
<div id="member-detail-{{ row.profile.user_id }}" class="json-render" hidden></div>
</td>
</tr>
{% empty %}
<tr><td colspan="4">暂无成员。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="ops-panel" data-group-feature-panel="channels" hidden>
<h4>微信绑定管理</h4>
<p>渠道与绑定按当前管理组成员展示,只显示绑定摘要、最近活动和可执行操作,不展示私密正文。</p>
<div class="tablewrap">
<table class="compact-table ops-table">
<thead><tr><th>成员</th><th>微信绑定/渠道</th><th>最近活动</th><th>操作</th></tr></thead>
<tbody>
{% for row in group_members %}
<tr>
<td><b>{{ row.profile.display_name }}</b><div class="muted">{{ row.profile.user_id }}</div></td>
<td>{{ row.channel_summary }}</td>
<td>{{ row.last_active_label }}</td>
<td>
<div class="ops-actions">
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/members/{{ row.profile.user_id }}/send-binding-guide" data-action-method="POST">发送绑定引导</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/members/{{ row.profile.user_id }}" data-action-method="GET" data-render-target="channel-member-detail-{{ row.profile.user_id }}">查看成员详情</button>
</div>
<div id="channel-member-detail-{{ row.profile.user_id }}" class="json-render" hidden></div>
</td>
</tr>
{% empty %}
<tr><td colspan="4">暂无成员渠道。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="ops-panel" data-group-feature-panel="usage" hidden>
<h4>组用量明细</h4>
<p>组用量按当前管理组聚合 Token,并按成员展示累计 Token、额度策略、权限组和最近活动。</p>
<div class="tablewrap">
<table class="compact-table ops-table">
<thead><tr><th>成员</th><th>累计 Token</th><th>额度策略</th><th>权限组</th><th>最近活动</th><th>操作</th></tr></thead>
<tbody>
{% for row in group_members %}
<tr>
<td><b>{{ row.profile.display_name }}</b><div class="muted">{{ row.profile.user_id }}</div></td>
<td>{{ row.token_total|default:0 }}</td>
<td>{{ row.profile.quota_policy.name|default:"未指定" }}</td>
<td>{{ row.profile.primary_capability_group.name|default:"未指定" }}</td>
<td>{{ row.last_active_label }}</td>
<td><button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/members/{{ row.profile.user_id }}/quota-policy" data-action-method="PATCH" data-action-payload='{"quota_policy_id":{{ available_quota_policies.0.id|default:"0" }}}'>调整额度</button></td>
</tr>
{% empty %}
<tr><td colspan="6">暂无组用量记录。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="ops-panel" data-group-feature-panel="approvals" hidden>
<h4>审批中心</h4>
<p>审批中心按事项逐条处理,支持通过、拒绝、要求补充、限时通过和转交 Owner。</p>
<div class="tablewrap">
<table class="compact-table ops-table">
<thead><tr><th>事项</th><th>目标用户</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
{% for approval in group_approvals %}
<tr>
<td>{{ approval.action_type }}<div class="muted">{{ approval.risk_level }}</div></td>
<td>{{ approval.target_user.user_id|default:"-" }}</td>
<td>{{ approval.get_decision_display }}</td>
<td><div class="ops-actions">
<button type="button" class="btn primary" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/approvals/{{ approval.approval_id }}/approve" data-action-method="POST">通过</button>
<button type="button" class="btn danger" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/approvals/{{ approval.approval_id }}/reject" data-action-method="POST">拒绝</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/approvals/{{ approval.approval_id }}/request-more-info" data-action-method="POST">要求补充</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/approvals/{{ approval.approval_id }}/approve" data-action-method="POST" data-action-payload='{"effective_until":"limited"}'>限时通过</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/approvals/{{ approval.approval_id }}/delegate-owner" data-action-method="POST">转 Owner</button>
</div></td>
</tr>
{% empty %}
<tr><td colspan="4">暂无待处理审批。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="ops-panel" data-group-feature-panel="operations" hidden>
<h4>操作记录</h4>
<p>查看审批、成员、邀请码、权限、额度、渠道和回滚操作的记录链路。</p>
<div class="button-row">
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/operations" data-action-method="GET" data-render-target="group-action-json">查看详情</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/knowledge-bases" data-action-method="GET" data-render-target="group-action-json">查看组数据</button>
<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/audit" data-action-method="GET" data-render-target="group-action-json">导出记录</button>
</div>
<div class="tablewrap">
<table class="compact-table">
<thead><tr><th>操作</th><th>对象</th><th>审计</th><th>操作</th></tr></thead>
<tbody>
{% for operation in group_operations %}
<tr><td>{{ operation.operation_type }}</td><td>{{ operation.target_user.user_id|default:"-" }}</td><td>{{ operation.trace_id }}</td><td><div class="ops-actions"><button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/operations/{{ operation.operation_id }}" data-action-method="GET" data-render-target="operation-detail-{{ operation.operation_id }}">查看变更前后</button>{% if operation.rollback_available %}<button type="button" class="btn" data-action-url="/api/group-admin/{{ selected_admin_group.id }}/operations/{{ operation.operation_id }}/rollback" data-action-method="POST">回滚</button>{% endif %}</div><div id="operation-detail-{{ operation.operation_id }}" class="json-render" hidden></div></td></tr>
{% empty %}
<tr><td colspan="4">暂无操作记录。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</section>
{% endif %}
</main>
</div>
<div id="wechat-unbind-confirm" class="confirm-backdrop" hidden>
<div class="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="wechat-unbind-confirm-title">
<h3 id="wechat-unbind-confirm-title">解除绑定</h3>
<p id="wechat-unbind-confirm-message">解除绑定后,该微信将不能继续收发消息。是否删除该渠道记忆?</p>
<div class="button-row">
<button type="button" class="btn" data-unbind-choice="keep">保留记忆并解除绑定</button>
<button type="button" class="btn danger" data-unbind-choice="delete">删除记忆并解除绑定</button>
<button type="button" class="btn" data-unbind-choice="cancel">取消</button>
</div>
</div>
</div>
<script>
(function(){
function csrfToken() {
var input = document.querySelector('input[name="csrfmiddlewaretoken"]');
return input ? input.value : '';
}
function result(message, isError) {
var el = document.getElementById('group-action-result');
if (!el) return;
el.hidden = false;
el.style.borderColor = isError ? '#ffc7c7' : '#dbeafe';
el.style.background = isError ? '#fff0f0' : '#f8fbff';
el.style.color = isError ? '#b42318' : '#174ea6';
el.textContent = message;
}
function resultHtml(html, isError) {
var el = document.getElementById('group-action-result');
if (!el) return null;
el.hidden = false;
el.style.borderColor = isError ? '#ffc7c7' : '#dbeafe';
el.style.background = isError ? '#fff0f0' : '#f8fbff';
el.style.color = isError ? '#b42318' : '#174ea6';
el.innerHTML = html;
return el;
}
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>"']/g, function(ch){
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch];
});
}
function copyTextToClipboard(text, okMessage) {
if (!text) { result('暂无可复制内容', true); return; }
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function(){ result(okMessage || '已复制。'); }).catch(function(){ result('复制失败,请重新打开详情。', true); });
} else {
result(text);
}
}
function renderRows(rows, columns) {
if (!rows || !rows.length) return '<p>暂无记录。</p>';
var head = columns.map(function(col){ return '<th>' + escapeHtml(col.label) + '</th>'; }).join('');
var body = rows.map(function(row){
return '<tr>' + columns.map(function(col){ return '<td>' + escapeHtml(row[col.key] || '') + '</td>'; }).join('') + '</tr>';
}).join('');
return '<div class="tablewrap"><table class="compact-table"><thead><tr>' + head + '</tr></thead><tbody>' + body + '</tbody></table></div>';
}
function renderJson(targetId, data) {
if (!targetId) return;
var target = document.getElementById(targetId);
if (!target) return;
var html = '';
if (data.redemptions) {
html = renderRows(data.redemptions, [
{key:'created_at', label:'时间'},
{key:'user_id', label:'用户 ID'},
{key:'display_name', label:'显示名称'},
{key:'result', label:'结果'},
{key:'reason', label:'原因'}
]);
} else if (data.invites) {
html = renderRows(data.invites, [
{key:'full_code', label:'邀请码'},
{key:'status_label', label:'状态'},
{key:'created_at', label:'创建时间'},
{key:'expires_at', label:'有效期'},
{key:'used_count', label:'已用'},
{key:'max_uses', label:'有效数量'},
{key:'default_role', label:'角色'}
]);
} else if (data.members) {
html = renderRows(data.members, [
{key:'user_id', label:'用户 ID'},
{key:'display_name', label:'显示名称'},
{key:'role', label:'角色'},
{key:'status_label', label:'状态'}
]);
} else if (data.approvals) {
html = renderRows(data.approvals, [
{key:'created_at', label:'时间'},
{key:'action_type', label:'事项'},
{key:'target_user_id', label:'目标用户'},
{key:'decision_label', label:'状态'}
]);
} else if (data.operations) {
html = renderRows(data.operations, [
{key:'created_at', label:'时间'},
{key:'operation_type', label:'操作'},
{key:'target_user_id', label:'对象'},
{key:'trace_id', label:'审计'}
]);
} else {
html = '<pre>' + escapeHtml(JSON.stringify(data, null, 2)) + '</pre>';
}
target.hidden = false;
target.innerHTML = html;
}
function renderInviteCreateSuccess(data) {
var invite = data.invite || {};
var code = data.full_code || invite.full_code || '';
var sync = data.casdoor_sync || {};
var role = invite.default_role === 'group_admin' ? '管理员' : (invite.default_role === 'owner' ? 'Owner' : '用户');
var syncLabel = sync.status === 'synced' ? 'Casdoor 已同步' : (sync.status ? ('Casdoor ' + sync.status) : 'Casdoor 未返回');
var usage = (invite.used_count == null ? 0 : invite.used_count) + ' / ' + (invite.max_uses == null ? '-' : invite.max_uses);
if (invite.remaining_uses != null) usage += ',剩余 ' + invite.remaining_uses;
var rows = [
['完整邀请码', code || '未返回'],
['标题', invite.title || '未命名'],
['状态', invite.status_label || invite.status || '可用'],
['使用数量/有效数量', usage],
['默认角色', role],
['有效期', invite.expires_at || '未设置'],
['身份同步', syncLabel]
];
var table = rows.map(function(row){
return '<tr><th>' + escapeHtml(row[0]) + '</th><td>' + escapeHtml(row[1]) + '</td></tr>';
}).join('');
var copyButton = code ? '<button type="button" class="btn" data-created-invite-copy data-copy-text="' + escapeHtml(code) + '">复制完整码</button>' : '';
var html = '<strong>邀请码创建成功</strong><p class="muted">该邀请码已经入库;注册时直接填写完整邀请码。</p>' +
'<div class="tablewrap"><table class="compact-table"><tbody>' + table + '</tbody></table></div>' +
'<div class="button-row">' + copyButton + '</div>';
var el = resultHtml(html, false);
var jsonTarget = document.getElementById('group-action-json');
if (jsonTarget) {
jsonTarget.hidden = true;
jsonTarget.innerHTML = '';
}
var inviteListTarget = document.getElementById('invite-list-json');
if (inviteListTarget) {
var row = {};
Object.keys(invite).forEach(function(key){ row[key] = invite[key]; });
row.full_code = code;
renderJson('invite-list-json', {invites: [row]});
}
var createdCopy = el ? el.querySelector('[data-created-invite-copy]') : null;
if (createdCopy) {
createdCopy.addEventListener('click', function(){
copyTextToClipboard(createdCopy.getAttribute('data-copy-text') || '', '已复制完整邀请码。');
});
}
}
function formPayload(form) {
var payload = {};
new FormData(form).forEach(function(value, key){
if (key === 'csrfmiddlewaretoken') return;
if (value === '') return;
payload[key] = value;
});
return payload;
}
function activate(id) {
var target = document.getElementById(id);
if (!target || target.getAttribute('data-role-hidden') === '1') return;
document.querySelectorAll('.nav button').forEach(function(b){ b.classList.toggle('active', b.dataset.sec === id); });
document.querySelectorAll('.section').forEach(function(s){ s.classList.toggle('active', s.id === id && s.getAttribute('data-role-hidden') !== '1'); });
window.scrollTo({top:0, behavior:'smooth'});
}
function activateFromHash() {
var id = (window.location.hash || '').replace(/^#/, '');
if (id) activate(id);
}
function roleAllows(el, role) {
var raw = el.getAttribute('data-role-only') || 'user admin owner';
return raw.split(/\s+/).indexOf(role) !== -1;
}
function applyRole(role) {
document.querySelectorAll('[data-role-only]').forEach(function(el){
var allowed = roleAllows(el, role);
el.setAttribute('data-role-hidden', allowed ? '0' : '1');
if (el.classList.contains('section') && !allowed) el.classList.remove('active');
});
var current = document.querySelector('.section.active');
if (!current || current.getAttribute('data-role-hidden') === '1') {
var first = Array.prototype.find.call(document.querySelectorAll('.nav button[data-sec]'), function(btn){
return btn.getAttribute('data-role-hidden') !== '1';
});
if (first) activate(first.dataset.sec);
}
}
var roleSelect = document.getElementById('permission-preview');
if (roleSelect) {
roleSelect.addEventListener('change', function(){ applyRole(roleSelect.value || 'user'); });
applyRole(roleSelect.value || 'user');
}
document.querySelectorAll('.nav button[data-sec]').forEach(function(btn){
btn.addEventListener('click', function(){ activate(btn.dataset.sec); });
});
document.querySelectorAll('[data-jump]').forEach(function(btn){
btn.addEventListener('click', function(){ activate(btn.dataset.jump); });
});
activateFromHash();
window.addEventListener('hashchange', activateFromHash);
function wechatBindResult(message, isError) {
var el = document.getElementById('wechat-bind-result');
if (!el) return;
el.hidden = false;
el.style.borderColor = isError ? '#ffc7c7' : '#dbeafe';
el.style.background = isError ? '#fff0f0' : '#f8fbff';
el.style.color = isError ? '#b42318' : '#174ea6';
el.textContent = message;
}
document.querySelectorAll('form[data-wechat-bind-form]').forEach(function(form){
form.addEventListener('submit', function(evt){
if (!window.fetch || !window.FormData) return;
evt.preventDefault();
var button = form.querySelector('button[type="submit"]');
var originalText = button ? button.textContent : '';
if (button) {
button.disabled = true;
button.textContent = '正在生成二维码...';
}
wechatBindResult('正在生成微信绑定二维码,请稍候。');
fetch(form.getAttribute('action'), {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store',
headers: {
'X-CSRFToken': csrfToken(),
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: new FormData(form)
})
.then(function(resp){
return resp.text().then(function(text){
var data = {};
try { data = text ? JSON.parse(text) : {}; } catch (err) { data = {raw: text}; }
if (!resp.ok) throw data;
return data;
});
})
.then(function(data){
wechatBindResult('二维码已生成,正在打开扫码页面。');
window.location.href = data.redirect_url || form.getAttribute('action');
})
.catch(function(err){
wechatBindResult(err.message || err.error || err.detail || '二维码生成失败,请稍后重试。', true);
if (button) {
button.disabled = false;
button.textContent = originalText;
}
});
});
});
function hideDetailPanels(exceptId) {
document.querySelectorAll('.detail-panel').forEach(function(panel){
if (panel.id !== exceptId) {
panel.hidden = true;
}
});
document.querySelectorAll('[data-detail-target]').forEach(function(trigger){
if (trigger.getAttribute('data-detail-target') !== exceptId) {
trigger.setAttribute('aria-expanded', 'false');
}
});
}
function showDetailPanel(targetId) {
var target = document.getElementById(targetId);
if (!target) return;
var willOpen = target.hidden;
hideDetailPanels(targetId);
if (willOpen) {
target.hidden = false;
} else {
target.hidden = true;
}
document.querySelectorAll('[data-detail-target="' + targetId + '"]').forEach(function(trigger){
if (willOpen) {
trigger.setAttribute('aria-expanded', 'true');
} else {
trigger.setAttribute('aria-expanded', 'false');
}
});
}
function openGroupFeature(feature) {
document.querySelectorAll('[data-group-feature-panel]').forEach(function(panel){
panel.hidden = panel.getAttribute('data-group-feature-panel') !== feature;
});
document.querySelectorAll('[data-group-feature-target]').forEach(function(btn){
btn.setAttribute('aria-pressed', btn.getAttribute('data-group-feature-target') === feature ? 'true' : 'false');
});
var firstPanel = document.querySelector('[data-group-feature-panel="' + feature + '"]');
if (firstPanel && firstPanel.scrollIntoView) {
firstPanel.scrollIntoView({behavior:'smooth', block:'start'});
}
}
document.querySelectorAll('[data-detail-target]').forEach(function(el){
el.setAttribute('aria-expanded', 'false');
function toggle(){
var targetId = el.getAttribute('data-detail-target');
showDetailPanel(targetId);
}
el.addEventListener('click', toggle);
el.addEventListener('keydown', function(evt){
if (evt.key === 'Enter' || evt.key === ' ') {
evt.preventDefault();
toggle();
}
});
});
document.querySelectorAll('[data-group-feature-target]').forEach(function(btn){
function open(){
openGroupFeature(btn.getAttribute('data-group-feature-target'));
}
btn.addEventListener('click', open);
btn.addEventListener('keydown', function(evt){
if (evt.key === 'Enter' || evt.key === ' ') {
evt.preventDefault();
open();
}
});
});
document.querySelectorAll('[data-remark-edit]').forEach(function(btn){
btn.addEventListener('click', function(){
var id = btn.getAttribute('data-remark-edit');
var form = document.querySelector('[data-remark-form="' + id + '"]');
if (!form) return;
var expanded = form.hidden;
form.hidden = !expanded;
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
if (expanded) {
var input = form.querySelector('input[name="remark"]');
if (input) {
input.focus();
input.select();
}
}
});
});
document.querySelectorAll('[data-remark-cancel]').forEach(function(btn){
btn.addEventListener('click', function(){
var id = btn.getAttribute('data-remark-cancel');
var form = document.querySelector('[data-remark-form="' + id + '"]');
var editButton = document.querySelector('[data-remark-edit="' + id + '"]');
if (form) form.hidden = true;
if (editButton) editButton.setAttribute('aria-expanded', 'false');
});
});
var pendingUnbindForm = null;
var unbindDialog = document.getElementById('wechat-unbind-confirm');
var unbindMessage = document.getElementById('wechat-unbind-confirm-message');
document.querySelectorAll('[data-unbind-form]').forEach(function(form){
form.addEventListener('submit', function(evt){
if (form.getAttribute('data-confirmed') === '1') return;
evt.preventDefault();
pendingUnbindForm = form;
var label = form.getAttribute('data-binding-label') || '该微信渠道';
if (unbindMessage) {
unbindMessage.textContent = '解除绑定后,' + label + ' 将不能继续收发消息。是否同时删除该渠道记忆?';
}
if (unbindDialog) unbindDialog.hidden = false;
});
});
if (unbindDialog) {
unbindDialog.addEventListener('click', function(evt){
if (evt.target === unbindDialog) {
unbindDialog.hidden = true;
pendingUnbindForm = null;
}
});
unbindDialog.querySelectorAll('[data-unbind-choice]').forEach(function(btn){
btn.addEventListener('click', function(){
var choice = btn.getAttribute('data-unbind-choice');
if (choice === 'cancel') {
unbindDialog.hidden = true;
pendingUnbindForm = null;
return;
}
if (!pendingUnbindForm) return;
var input = pendingUnbindForm.querySelector('input[name="delete_memory"]');
if (input) input.value = choice === 'delete' ? 'yes' : 'no';
pendingUnbindForm.setAttribute('data-confirmed', '1');
pendingUnbindForm.submit();
});
});
}
document.querySelectorAll('[data-copy-text]').forEach(function(btn){
btn.addEventListener('click', function(){
var text = btn.getAttribute('data-copy-text') || '';
if (!text) { result(btn.getAttribute('data-missing') || '暂无可复制内容', true); return; }
copyTextToClipboard(text, '已复制完整码。');
});
});
function agentResult(message, isError) {
var target = document.getElementById('agent-action-result');
if (!target) { result(message, isError); return; }
target.hidden = false;
target.textContent = message;
target.style.borderColor = isError ? '#ffc7c7' : '#dbeafe';
target.style.color = isError ? '#991b1b' : '#174ea6';
}
document.querySelectorAll('[data-agent-create-toggle]').forEach(function(btn){
btn.addEventListener('click', function(){
var panel = document.getElementById('agent-create-panel');
if (!panel) return;
var nextHidden = !panel.hidden ? true : false;
panel.hidden = nextHidden;
document.querySelectorAll('[data-agent-create-toggle]').forEach(function(toggle){
toggle.setAttribute('aria-expanded', nextHidden ? 'false' : 'true');
});
});
});
document.querySelectorAll('[data-agent-create-submit]').forEach(function(btn){
btn.addEventListener('click', function(){
agentResult('创建申请已提交审批,请在审批队列查看处理进度。');
});
});
document.querySelectorAll('[data-agent-action]').forEach(function(btn){
btn.addEventListener('click', function(){
agentResult(btn.getAttribute('data-agent-action') + '已打开;涉及权限变更的动作会进入审批队列。');
});
});
document.querySelectorAll('[data-action-missing]').forEach(function(btn){
btn.addEventListener('click', function(){
result(btn.getAttribute('data-action-missing') || '当前操作暂不可用', true);
});
});
document.querySelectorAll('form[data-json-form]').forEach(function(form){
form.addEventListener('submit', function(evt){
evt.preventDefault();
var method = form.getAttribute('data-action-method') || 'POST';
var targetId = form.getAttribute('data-render-target') || 'group-action-json';
var options = {
method: method,
credentials: 'same-origin',
cache: 'no-store',
headers: {'X-CSRFToken': csrfToken(), 'Accept': 'application/json', 'Content-Type': 'application/json'},
body: JSON.stringify(formPayload(form))
};
var buttons = form.querySelectorAll('button');
buttons.forEach(function(button){ button.disabled = true; });
fetch(form.getAttribute('data-action-url'), options)
.then(function(resp){
return resp.text().then(function(text){
var data = {};
try { data = text ? JSON.parse(text) : {}; } catch (err) { data = {raw: text}; }
if (!resp.ok) throw data;
return data;
});
})
.then(function(data){
if (form.getAttribute('data-json-form') === 'invite-create') {
renderInviteCreateSuccess(data);
} else {
result('操作已提交:' + (data.marker || data.audit_marker || data.notification_marker || data.operation_id || 'OK'));
renderJson(targetId, data);
}
})
.catch(function(err){
result('操作失败:' + (err.error || err.marker || err.detail || err.raw || 'request_failed'), true);
})
.finally(function(){ buttons.forEach(function(button){ button.disabled = false; }); });
});
});
document.querySelectorAll('button[data-action-url], a[data-action-url]').forEach(function(btn){
btn.addEventListener('click', function(){
var missing = btn.getAttribute('data-missing');
if (missing) { result(missing, true); return; }
var confirmText = btn.getAttribute('data-confirm');
if (confirmText && !window.confirm(confirmText)) return;
var method = btn.getAttribute('data-action-method') || 'POST';
var payload = btn.getAttribute('data-action-payload');
var options = {
method: method,
credentials: 'same-origin',
cache: 'no-store',
headers: {'X-CSRFToken': csrfToken(), 'Accept': 'application/json'}
};
if (method !== 'GET') {
options.headers['Content-Type'] = 'application/json';
options.body = payload || '{}';
}
btn.disabled = true;
fetch(btn.getAttribute('data-action-url'), options)
.then(function(resp){
return resp.text().then(function(text){
var data = {};
try { data = text ? JSON.parse(text) : {}; } catch (err) { data = {raw: text}; }
if (!resp.ok) throw data;
return data;
});
})
.then(function(data){
result('操作已提交:' + (data.marker || data.audit_marker || data.notification_marker || data.operation_id || 'OK'));
renderJson(btn.getAttribute('data-render-target'), data);
})
.catch(function(err){
result('操作失败:' + (err.error || err.marker || err.detail || err.raw || 'request_failed'), true);
})
.finally(function(){ btn.disabled = false; });
});
});
})();
</script>
{% if wechat_pending_binding and wechat_plugin_qr_available %}
<script>
(function(){
var panel = document.getElementById('wechat-qr-panel');
var el = document.getElementById('wechat-qr-countdown');
var statusEl = document.getElementById('wechat-qr-status');
var qrImage = document.getElementById('wechat-qr-image');
if (!panel || !el || !statusEl) return;
var remaining = parseInt(el.getAttribute('data-remaining-seconds') || '0', 10);
var pollTimer = null;
var countdownTimer = null;
function render(){
if (remaining <= 0) { el.textContent = '已过期,请重新生成'; return; }
var minutes = Math.floor(remaining / 60);
var seconds = remaining % 60;
el.textContent = minutes + ' 分 ' + seconds + ' 秒';
}
function stopTerminal(message, countdownText){
statusEl.textContent = message;
if (countdownText) el.textContent = countdownText;
if (qrImage) qrImage.style.opacity = '0.45';
if (pollTimer) clearInterval(pollTimer);
if (countdownTimer) clearInterval(countdownTimer);
}
function applyStatus(data){
if (!data) return;
if (typeof data.remaining_seconds === 'number') {
remaining = data.remaining_seconds;
render();
}
if (data.confirming_after_expiry) {
statusEl.textContent = data.message || '扫码结果正在确认,请稍候。';
el.textContent = data.countdown_text || '正在确认绑定';
return;
}
if (data.status === 'bound') {
stopTerminal(data.message || '绑定已完成。', '已完成');
window.setTimeout(function(){ window.location.href = panel.getAttribute('data-account-url') || '/portal/me/'; }, 900);
return;
}
if (data.status === 'expired') {
stopTerminal(data.message || '二维码已过期,请重新生成。', '已过期,请重新生成');
return;
}
if (data.status === 'failed') {
var failedCountdown = data.countdown_text || (data.error_message === 'openclaw_fresh_wechat_identity_required' || data.error_message === 'openclaw_binding_proof_required' ? '未完成绑定' : '绑定失败');
stopTerminal(data.message || '绑定失败,请重新生成。', failedCountdown);
return;
}
statusEl.textContent = data.message || '等待扫码确认。';
}
function poll(){
fetch(panel.getAttribute('data-status-url'), {credentials: 'same-origin', cache: 'no-store'})
.then(function(resp){ return resp.ok ? resp.json() : null; })
.then(applyStatus)
.catch(function(){});
}
render();
poll();
countdownTimer = setInterval(function(){ if (remaining > 0) { remaining -= 1; render(); } }, 1000);
pollTimer = setInterval(poll, 3000);
})();
</script>
{% endif %}
{% else %}
<main class="main">
<div class="card">
<h1>当前账号没有关联用户资料</h1>
<p class="muted">请重新登录或联系管理员检查 Casdoor 账号同步。</p>
</div>
</main>
{% endif %}
{% endblock %}