1741 lines
98 KiB
HTML
1741 lines
98 KiB
HTML
{% 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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|