重构 monorepo 并完善网页端订阅与首页体验
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构 - 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐 - 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作 - 新增管理后台、支付与云端文件 API,更新 README 与项目规范 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,899 @@
|
||||
@import url('../../../assets/css/tokens.css');
|
||||
@import url('../../../assets/css/components.css');
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--soon-bg-deep);
|
||||
font-family: var(--soon-font);
|
||||
color: var(--soon-text);
|
||||
}
|
||||
|
||||
.soon-admin-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.soon-admin-header {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--soon-space-lg);
|
||||
background: var(--soon-bg-panel);
|
||||
border-bottom: 1px solid var(--soon-border);
|
||||
z-index: 100;
|
||||
}
|
||||
.soon-admin-header__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--soon-space-sm);
|
||||
color: var(--soon-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.soon-admin-header__brand img { width: 28px; height: 28px; }
|
||||
.soon-admin-header__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--soon-space-md);
|
||||
font-size: 13px;
|
||||
color: var(--soon-text-muted);
|
||||
}
|
||||
|
||||
.soon-admin-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.soon-admin-sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: var(--soon-bg-elevated);
|
||||
border-right: 1px solid var(--soon-border);
|
||||
padding: var(--soon-space-md) 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.soon-admin-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--soon-space-sm);
|
||||
padding: 12px var(--soon-space-lg);
|
||||
color: var(--soon-text);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
.soon-admin-nav-item img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.soon-admin-nav-item:hover { background: rgba(0, 150, 136, 0.08); }
|
||||
.soon-admin-nav-item.is-active {
|
||||
color: var(--soon-accent);
|
||||
border-left-color: var(--soon-accent);
|
||||
background: rgba(0, 150, 136, 0.12);
|
||||
}
|
||||
.soon-admin-nav-item.is-active img { opacity: 1; }
|
||||
|
||||
.soon-admin-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--soon-space-lg);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.soon-admin-page { width: 100%; }
|
||||
|
||||
.soon-admin-page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--soon-space-md);
|
||||
margin-bottom: var(--soon-space-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.soon-admin-page-header__main h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--soon-text-strong);
|
||||
}
|
||||
.soon-admin-page-header__desc {
|
||||
margin: var(--soon-space-xs) 0 0;
|
||||
font-size: var(--soon-font-sm);
|
||||
color: var(--soon-text-muted);
|
||||
}
|
||||
.soon-admin-page-header__actions {
|
||||
display: flex;
|
||||
gap: var(--soon-space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.soon-admin-card {
|
||||
background: var(--soon-bg-panel);
|
||||
border: 1px solid var(--soon-border);
|
||||
border-radius: var(--soon-radius);
|
||||
padding: var(--soon-space-md);
|
||||
margin-bottom: var(--soon-space-lg);
|
||||
}
|
||||
.soon-admin-card__title {
|
||||
margin: 0 0 var(--soon-space-md);
|
||||
font-size: var(--soon-font-md);
|
||||
font-weight: 500;
|
||||
color: var(--soon-text-strong);
|
||||
}
|
||||
|
||||
.soon-admin-filter-bar {
|
||||
display: flex;
|
||||
gap: var(--soon-space-sm);
|
||||
margin-bottom: var(--soon-space-md);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.soon-admin-filter-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.soon-admin-filter-field__label {
|
||||
font-size: var(--soon-font-sm);
|
||||
color: var(--soon-text-muted);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.soon-admin-filter-bar .soon-input--sm {
|
||||
max-width: 200px;
|
||||
min-width: 120px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.soon-admin-filter-bar .soon-input--date {
|
||||
min-width: 148px;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.soon-admin-date-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.soon-admin-date-wrap .soon-input--date {
|
||||
position: relative;
|
||||
padding-right: 32px;
|
||||
background-color: var(--soon-bg-deep);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23eef1f4' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='5' width='16' height='15' rx='2'/%3E%3Cpath d='M8 3v4M16 3v4M4 10h16'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
body.soon-admin-page .soon-admin-filter-bar select.soon-input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23eef1f4' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: 14px;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
body.soon-admin-page .soon-admin-date-wrap input[type="date"].soon-input::-webkit-calendar-picker-indicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
body.soon-admin-page .soon-admin-date-wrap input[type="date"].soon-input::-webkit-datetime-edit {
|
||||
color: var(--soon-text-strong);
|
||||
}
|
||||
|
||||
body.soon-admin-page .soon-admin-date-wrap input[type="date"].soon-input::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.soon-admin-stat-grid,
|
||||
.soon-admin-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: var(--soon-space-md);
|
||||
margin-bottom: var(--soon-space-xl);
|
||||
}
|
||||
.soon-admin-stat {
|
||||
background: var(--soon-bg-panel);
|
||||
border: 1px solid var(--soon-border);
|
||||
border-radius: var(--soon-radius);
|
||||
padding: var(--soon-space-lg);
|
||||
animation: soon-fadeInUp 0.4s var(--soon-ease);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.soon-admin-stat:hover { border-color: var(--soon-accent); }
|
||||
.soon-admin-stat__label { font-size: 13px; color: var(--soon-text-muted); margin-bottom: var(--soon-space-sm); }
|
||||
.soon-admin-stat__value { font-size: 28px; color: var(--soon-accent); font-weight: 600; }
|
||||
.soon-admin-stat__hint {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--soon-text-muted);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.soon-admin-stat--link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.soon-admin-stat--link:hover {
|
||||
border-color: var(--soon-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.soon-admin-stat-grid--dash {
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.soon-admin-alert-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--soon-space-md);
|
||||
margin-bottom: var(--soon-space-lg);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--soon-radius-md);
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border: 1px solid rgba(255, 193, 7, 0.35);
|
||||
font-size: var(--soon-font-sm);
|
||||
color: #ffe082;
|
||||
}
|
||||
.soon-admin-alert-bar strong { color: #fff8e1; }
|
||||
|
||||
.soon-admin-dash-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
gap: var(--soon-space-lg);
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.soon-admin-dash-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.soon-admin-card__head-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--soon-space-sm);
|
||||
margin-bottom: var(--soon-space-md);
|
||||
}
|
||||
.soon-admin-card__head-row .soon-admin-card__title { margin: 0; }
|
||||
.soon-admin-card__link {
|
||||
font-size: var(--soon-font-xs);
|
||||
color: var(--soon-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.soon-admin-card__link:hover { text-decoration: underline; }
|
||||
|
||||
.soon-admin-quick-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.soon-admin-quick-link {
|
||||
display: block;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--soon-radius-sm);
|
||||
background: var(--soon-bg-deep);
|
||||
border: 1px solid var(--soon-border);
|
||||
color: var(--soon-text);
|
||||
font-size: var(--soon-font-sm);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.soon-admin-quick-link:hover {
|
||||
border-color: var(--soon-accent);
|
||||
background: rgba(0, 150, 136, 0.08);
|
||||
}
|
||||
|
||||
.soon-admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--soon-bg-panel);
|
||||
border-radius: var(--soon-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.soon-admin-table th,
|
||||
.soon-admin-table td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--soon-border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.soon-admin-table th {
|
||||
background: var(--soon-bg-elevated);
|
||||
color: var(--soon-text-strong);
|
||||
font-weight: 500;
|
||||
}
|
||||
.soon-admin-table tr:hover td { background: rgba(255, 255, 255, 0.02); }
|
||||
|
||||
.soon-admin-drawer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 9000;
|
||||
}
|
||||
.soon-admin-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 420px;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
background: var(--soon-bg-panel);
|
||||
border-left: 1px solid var(--soon-border);
|
||||
z-index: 9001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: soon-fadeInUp 0.25s var(--soon-ease);
|
||||
}
|
||||
.soon-admin-drawer__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--soon-space-md) var(--soon-space-lg);
|
||||
border-bottom: 1px solid var(--soon-border);
|
||||
}
|
||||
.soon-admin-drawer__header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--soon-font-lg);
|
||||
color: var(--soon-text-strong);
|
||||
}
|
||||
.soon-admin-drawer__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--soon-text-muted);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.soon-admin-drawer__body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: var(--soon-space-lg);
|
||||
}
|
||||
.soon-admin-detail-section {
|
||||
margin-bottom: var(--soon-space-lg);
|
||||
}
|
||||
.soon-admin-detail-section h4 {
|
||||
margin: 0 0 var(--soon-space-sm);
|
||||
font-size: var(--soon-font-sm);
|
||||
color: var(--soon-text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.soon-admin-detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
font-size: var(--soon-font-sm);
|
||||
border-bottom: 1px solid var(--soon-border-subtle);
|
||||
}
|
||||
.soon-admin-detail-row__label { color: var(--soon-text-muted); }
|
||||
.soon-admin-detail-row__value { color: var(--soon-text-strong); }
|
||||
|
||||
.soon-admin-checklist {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--soon-space-lg);
|
||||
padding: 0;
|
||||
}
|
||||
.soon-admin-checklist li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--soon-space-sm);
|
||||
padding: 8px 0;
|
||||
font-size: var(--soon-font-sm);
|
||||
}
|
||||
.soon-admin-checklist__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.soon-admin-checklist__dot--ok { background: var(--soon-success); }
|
||||
.soon-admin-checklist__dot--miss { background: var(--soon-danger); }
|
||||
|
||||
.soon-admin-upload {
|
||||
border: 2px dashed var(--soon-border);
|
||||
border-radius: var(--soon-radius);
|
||||
padding: var(--soon-space-xl);
|
||||
text-align: center;
|
||||
margin-bottom: var(--soon-space-lg);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.soon-admin-upload.is-dragover { border-color: var(--soon-accent); }
|
||||
|
||||
.soon-audit-timeline {
|
||||
border-left: 2px solid var(--soon-border);
|
||||
padding-left: var(--soon-space-lg);
|
||||
}
|
||||
.soon-audit-item {
|
||||
position: relative;
|
||||
padding-bottom: var(--soon-space-lg);
|
||||
animation: soon-fadeInUp 0.3s var(--soon-ease);
|
||||
}
|
||||
.soon-audit-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(-1 * var(--soon-space-lg) - 5px);
|
||||
top: 4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--soon-accent);
|
||||
}
|
||||
|
||||
.panel { display: none; }
|
||||
.panel.is-visible { display: block; }
|
||||
|
||||
.soon-admin-setting-hint {
|
||||
font-size: var(--soon-font-xs);
|
||||
color: var(--soon-text-muted);
|
||||
}
|
||||
|
||||
.soon-admin-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin-bottom: var(--soon-space-lg);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
|
||||
}
|
||||
.soon-admin-table-wrap::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.soon-admin-table-wrap::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.soon-admin-main,
|
||||
.soon-admin-sidebar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
|
||||
}
|
||||
.soon-admin-main::-webkit-scrollbar,
|
||||
.soon-admin-sidebar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.soon-admin-main::-webkit-scrollbar-thumb,
|
||||
.soon-admin-sidebar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.soon-admin-form { padding: var(--soon-space-md); }
|
||||
.soon-admin-form--plan {
|
||||
padding: var(--soon-space-lg);
|
||||
}
|
||||
.soon-admin-form--plan .soon-admin-form-panel:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.soon-admin-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 var(--soon-space-md);
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.soon-admin-form-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.soon-admin-form-checks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--soon-space-sm) var(--soon-space-lg);
|
||||
margin-top: var(--soon-space-sm);
|
||||
padding-top: var(--soon-space-sm);
|
||||
border-top: 1px solid var(--soon-border);
|
||||
}
|
||||
.soon-admin-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--soon-font-sm);
|
||||
color: var(--soon-text);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.soon-admin-check input { accent-color: var(--soon-accent); }
|
||||
.soon-admin-feature-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--soon-space-sm);
|
||||
}
|
||||
.soon-admin-feature-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.soon-admin-feature-row .soon-admin-feature-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.soon-admin-feature-del {
|
||||
flex-shrink: 0;
|
||||
min-width: 32px;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.soon-admin-form-hint--tight {
|
||||
margin-top: var(--soon-space-sm);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.soon-admin-hint {
|
||||
margin-bottom: var(--soon-space-md);
|
||||
color: var(--soon-text-muted);
|
||||
font-size: var(--soon-font-sm);
|
||||
}
|
||||
.soon-admin-hint summary { cursor: pointer; color: var(--soon-text); }
|
||||
.soon-admin-hint p { margin-top: var(--soon-space-sm); }
|
||||
.soon-admin-upload__label { color: var(--soon-accent); cursor: pointer; }
|
||||
.soon-admin-filters { display: flex; gap: var(--soon-space-sm); margin-bottom: var(--soon-space-md); flex-wrap: wrap; }
|
||||
.soon-admin-filters .soon-input--sm { width: auto; min-width: 120px; }
|
||||
|
||||
.soon-input--textarea {
|
||||
height: auto;
|
||||
min-height: 88px;
|
||||
padding: 8px 12px;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.soon-admin-logout-btn { margin-left: var(--soon-space-sm); }
|
||||
|
||||
.soon-audit-item__title { color: var(--soon-text-strong); font-size: var(--soon-font-md); }
|
||||
.soon-audit-item__meta { color: var(--soon-text-muted); font-size: var(--soon-font-xs); margin-top: var(--soon-space-xs); }
|
||||
.soon-audit-item__context { color: var(--soon-text-muted); font-size: var(--soon-font-xs); margin-top: var(--soon-space-xs); opacity: 0.85; }
|
||||
.soon-audit-item__details {
|
||||
margin-top: var(--soon-space-sm);
|
||||
font-size: var(--soon-font-xs);
|
||||
}
|
||||
.soon-audit-item__details summary {
|
||||
cursor: pointer;
|
||||
color: var(--soon-accent);
|
||||
user-select: none;
|
||||
}
|
||||
.soon-audit-item__details[open] summary { margin-bottom: var(--soon-space-xs); }
|
||||
.soon-admin-code {
|
||||
margin: var(--soon-space-xs) 0 0;
|
||||
padding: var(--soon-space-sm);
|
||||
background: var(--soon-bg-deep);
|
||||
border-radius: var(--soon-radius-sm);
|
||||
font-size: var(--soon-font-xs);
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.soon-admin-empty-row td { text-align: center; color: var(--soon-text-muted); padding: var(--soon-space-xl); }
|
||||
|
||||
.soon-admin-pager {
|
||||
margin-top: var(--soon-space-md);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--soon-space-md);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: var(--soon-space-sm) 0;
|
||||
border-top: 1px solid var(--soon-border);
|
||||
}
|
||||
.soon-admin-pager__left,
|
||||
.soon-admin-pager__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--soon-space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.soon-admin-pager__info {
|
||||
color: var(--soon-text-muted);
|
||||
font-size: var(--soon-font-sm);
|
||||
}
|
||||
.soon-admin-pager__size-label,
|
||||
.soon-admin-pager__jump-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--soon-font-xs);
|
||||
color: var(--soon-text-muted);
|
||||
}
|
||||
.soon-pager-jump { width: 56px; text-align: center; }
|
||||
.soon-admin-pager--empty { justify-content: flex-start; }
|
||||
|
||||
.soon-admin-tabs__nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
margin-bottom: var(--soon-space-md);
|
||||
background: var(--soon-bg-deep);
|
||||
border-radius: var(--soon-radius-md);
|
||||
border: 1px solid var(--soon-border);
|
||||
}
|
||||
.soon-admin-tabs__tab {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: var(--soon-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--soon-text-muted);
|
||||
font-size: var(--soon-font-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.soon-admin-tabs__tab:hover { color: var(--soon-text); background: rgba(255,255,255,0.04); }
|
||||
.soon-admin-tabs__tab.is-active {
|
||||
color: var(--soon-text-strong);
|
||||
background: rgba(0, 150, 136, 0.15);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 150, 136, 0.25);
|
||||
}
|
||||
.soon-admin-tabs__panel { display: none; }
|
||||
.soon-admin-tabs__panel.is-active { display: block; }
|
||||
|
||||
.soon-admin-form-panel {
|
||||
background: var(--soon-bg-elevated);
|
||||
border: 1px solid var(--soon-border);
|
||||
border-radius: var(--soon-radius-md);
|
||||
padding: var(--soon-space-lg);
|
||||
margin-bottom: var(--soon-space-md);
|
||||
}
|
||||
.soon-admin-form-panel__title {
|
||||
margin: 0 0 var(--soon-space-md);
|
||||
font-size: var(--soon-font-md);
|
||||
font-weight: 600;
|
||||
color: var(--soon-text-strong);
|
||||
}
|
||||
.soon-admin-form-panel__body .soon-form-field:last-child { margin-bottom: 0; }
|
||||
.soon-admin-form__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--soon-space-sm);
|
||||
margin-top: var(--soon-space-lg);
|
||||
padding-top: var(--soon-space-md);
|
||||
border-top: 1px solid var(--soon-border);
|
||||
}
|
||||
|
||||
.soon-admin-filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--soon-space-sm);
|
||||
align-items: flex-end;
|
||||
margin-bottom: var(--soon-space-md);
|
||||
padding: var(--soon-space-md);
|
||||
background: var(--soon-bg-elevated);
|
||||
border: 1px solid var(--soon-border);
|
||||
border-radius: var(--soon-radius-md);
|
||||
}
|
||||
.soon-admin-filter-bar__fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--soon-space-sm);
|
||||
flex: 1;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.soon-admin-filter-bar__actions {
|
||||
display: flex;
|
||||
gap: var(--soon-space-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.soon-admin-header__env {
|
||||
margin-left: var(--soon-space-sm);
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #ffe082;
|
||||
background: rgba(255, 193, 7, 0.12);
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.soon-admin-header__email {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.soon-admin-key-form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr auto;
|
||||
gap: var(--soon-space-sm);
|
||||
align-items: start;
|
||||
padding: var(--soon-space-md) 0;
|
||||
border-bottom: 1px solid var(--soon-border);
|
||||
}
|
||||
.soon-admin-key-form-row:last-child { border-bottom: none; }
|
||||
.soon-admin-key-form-row__label {
|
||||
font-size: var(--soon-font-sm);
|
||||
color: var(--soon-text);
|
||||
padding-top: 8px;
|
||||
}
|
||||
.soon-admin-key-form-row__status {
|
||||
font-size: var(--soon-font-xs);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.soon-admin-key-form-row__status--ok { color: var(--soon-accent); }
|
||||
.soon-admin-key-form-row__status--miss { color: var(--soon-danger); }
|
||||
|
||||
.soon-admin-btn-group {
|
||||
display: flex;
|
||||
gap: var(--soon-space-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.soon-admin-row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.soon-admin-table__actions {
|
||||
overflow: visible;
|
||||
min-width: 200px;
|
||||
}
|
||||
.soon-admin-action-menu {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.soon-admin-action-menu__panel {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
min-width: 120px;
|
||||
padding: 4px;
|
||||
background: var(--soon-bg-panel);
|
||||
border: 1px solid var(--soon-border);
|
||||
border-radius: var(--soon-radius-sm);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
z-index: 50;
|
||||
}
|
||||
.soon-admin-action-menu__panel--floating {
|
||||
position: fixed;
|
||||
right: auto;
|
||||
top: auto;
|
||||
z-index: 1200;
|
||||
}
|
||||
.soon-admin-action-menu.is-open .soon-admin-action-menu__panel {
|
||||
display: block;
|
||||
}
|
||||
.soon-admin-action-menu--more .soon-admin-action-menu__toggle {
|
||||
min-width: 52px;
|
||||
}
|
||||
.soon-admin-action-menu__item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--soon-text);
|
||||
font-size: var(--soon-font-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.soon-admin-action-menu__item:hover:not(:disabled) {
|
||||
background: rgba(0, 150, 136, 0.12);
|
||||
}
|
||||
.soon-admin-action-menu__item:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.soon-admin-action-menu__item.soon-admin-action-menu__item--danger {
|
||||
color: var(--soon-danger);
|
||||
}
|
||||
|
||||
.soon-admin-list-error {
|
||||
margin-top: var(--soon-space-md);
|
||||
padding: var(--soon-space-lg);
|
||||
text-align: center;
|
||||
background: var(--soon-bg-elevated);
|
||||
border: 1px solid var(--soon-border);
|
||||
border-radius: var(--soon-radius-md);
|
||||
}
|
||||
.soon-admin-list-error p {
|
||||
margin: 0 0 var(--soon-space-md);
|
||||
color: var(--soon-text-muted);
|
||||
font-size: var(--soon-font-sm);
|
||||
}
|
||||
|
||||
.soon-btn--xs {
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
font-size: var(--soon-font-xs);
|
||||
}
|
||||
|
||||
.soon-btn--danger {
|
||||
color: var(--soon-danger) !important;
|
||||
border-color: rgba(229, 115, 115, 0.45) !important;
|
||||
}
|
||||
|
||||
.soon-btn--danger:hover {
|
||||
color: #fff !important;
|
||||
background: var(--soon-danger) !important;
|
||||
border-color: var(--soon-danger) !important;
|
||||
}
|
||||
|
||||
.soon-admin-form-hint {
|
||||
margin: 0 0 var(--soon-space-md);
|
||||
font-size: var(--soon-font-xs);
|
||||
color: var(--soon-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.soon-admin-detail-empty {
|
||||
margin: 0;
|
||||
font-size: var(--soon-font-sm);
|
||||
color: var(--soon-text-muted);
|
||||
}
|
||||
|
||||
.soon-admin-drawer-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--soon-space-sm);
|
||||
margin-bottom: var(--soon-space-lg);
|
||||
padding-bottom: var(--soon-space-md);
|
||||
border-bottom: 1px solid var(--soon-border);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.soon-admin-sidebar { width: 56px; }
|
||||
.soon-admin-nav-item span { display: none; }
|
||||
.soon-admin-nav-item { justify-content: center; padding: 12px; }
|
||||
}
|
||||
|
||||
.soon-admin-empty-icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin-bottom: var(--soon-space-md);
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.92;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body.soon-admin-page .soon-empty--inline {
|
||||
min-height: 140px;
|
||||
padding: var(--soon-space-lg) var(--soon-space-md);
|
||||
}
|
||||
|
||||
body.soon-admin-page .soon-input-wrap .soon-input-icon {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
body.soon-admin-page .soon-input-wrap .soon-input-icon svg {
|
||||
stroke: #ffffff;
|
||||
}
|
||||
|
||||
body.soon-admin-page .layui-layer.soon-layer .layui-layer-ico,
|
||||
body.soon-admin-page .layui-layer-soon-layer .layui-layer-ico {
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.92;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var I = function () { return window.AdminI18n; };
|
||||
|
||||
function bar(innerHtml, actionsHtml) {
|
||||
return '<div class="soon-admin-filter-bar">' +
|
||||
'<div class="soon-admin-filter-bar__fields">' + innerHtml + '</div>' +
|
||||
(actionsHtml ? '<div class="soon-admin-filter-bar__actions">' + actionsHtml + '</div>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function field(label, inner) {
|
||||
return '<div class="soon-admin-filter-field"><span class="soon-admin-filter-field__label">' +
|
||||
I().esc(label) + '</span>' + inner + '</div>';
|
||||
}
|
||||
|
||||
function searchInput(id, value, placeholder) {
|
||||
return '<input type="search" id="' + id + '" class="soon-input soon-input--sm" value="' +
|
||||
I().esc(value || '') + '" placeholder="' + I().esc(placeholder || '搜索…') + '">';
|
||||
}
|
||||
|
||||
function select(id, optionsHtml) {
|
||||
return '<select id="' + id + '" class="soon-input soon-input--sm">' + optionsHtml + '</select>';
|
||||
}
|
||||
|
||||
window.AdminFilter = { bar: bar, field: field, searchInput: searchInput, select: select };
|
||||
})();
|
||||
@@ -0,0 +1,53 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var I = function () { return window.AdminI18n; };
|
||||
|
||||
function field(label, inner, hint) {
|
||||
var html = '<div class="soon-form-field"><label class="soon-form-label">' + I().esc(label) + '</label>' + inner;
|
||||
if (hint) {
|
||||
html += '<p class="soon-admin-form-hint">' + I().esc(hint) + '</p>';
|
||||
}
|
||||
return html + '</div>';
|
||||
}
|
||||
|
||||
function input(id, opts) {
|
||||
opts = opts || {};
|
||||
var type = opts.type || 'text';
|
||||
var cls = 'soon-input soon-input--sm' + (opts.className ? ' ' + opts.className : '');
|
||||
var attrs = 'id="' + id + '" class="' + cls + '"';
|
||||
if (opts.value != null) attrs += ' value="' + I().esc(opts.value) + '"';
|
||||
if (opts.placeholder) attrs += ' placeholder="' + I().esc(opts.placeholder) + '"';
|
||||
if (opts.disabled) attrs += ' disabled';
|
||||
if (opts.autocomplete) attrs += ' autocomplete="' + I().esc(opts.autocomplete) + '"';
|
||||
if (type === 'textarea') {
|
||||
return '<textarea ' + attrs.replace('value="' + I().esc(opts.value) + '"', '') + ' rows="' + (opts.rows || 4) + '">' +
|
||||
I().esc(opts.value || '') + '</textarea>';
|
||||
}
|
||||
return '<input type="' + type + '" ' + attrs + '>';
|
||||
}
|
||||
|
||||
function select(id, optionsHtml) {
|
||||
return '<select id="' + id + '" class="soon-input soon-input--sm">' + optionsHtml + '</select>';
|
||||
}
|
||||
|
||||
function actions(primaryLabel, primaryId, secondaryHtml) {
|
||||
return '<div class="soon-admin-form__actions">' +
|
||||
(secondaryHtml || '') +
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="' + (primaryId || 'adminFormSave') + '">' +
|
||||
I().esc(primaryLabel || '保存') + '</button></div>';
|
||||
}
|
||||
|
||||
function panel(title, bodyHtml) {
|
||||
return '<div class="soon-admin-form-panel"><h3 class="soon-admin-form-panel__title">' + I().esc(title) +
|
||||
'</h3><div class="soon-admin-form-panel__body">' + bodyHtml + '</div></div>';
|
||||
}
|
||||
|
||||
window.AdminForm = {
|
||||
field: field,
|
||||
input: input,
|
||||
select: select,
|
||||
actions: actions,
|
||||
panel: panel,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,168 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var I = function () { return window.AdminI18n; };
|
||||
|
||||
function itemDataset(it) {
|
||||
if (!it.dataset) return '';
|
||||
return Object.keys(it.dataset).map(function (k) {
|
||||
return ' data-' + k + '="' + I().esc(it.dataset[k]) + '"';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function menuItemBtn(it) {
|
||||
if (it.disabled) {
|
||||
return '<button type="button" class="soon-admin-action-menu__item" disabled title="' +
|
||||
I().esc(it.label) + '">' + I().esc(it.label) + '</button>';
|
||||
}
|
||||
var cls = 'soon-admin-action-menu__item' + (it.className ? ' ' + it.className : '');
|
||||
return '<button type="button" class="' + cls + '" data-action="' + I().esc(it.action) + '"' +
|
||||
itemDataset(it) + '>' + I().esc(it.label) + '</button>';
|
||||
}
|
||||
|
||||
function inlineBtn(it) {
|
||||
if (it.disabled) {
|
||||
return '<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-admin-row-actions__btn" disabled title="' +
|
||||
I().esc(it.label) + '">' + I().esc(it.label) + '</button>';
|
||||
}
|
||||
return '<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-admin-row-actions__btn" data-action="' +
|
||||
I().esc(it.action) + '"' + itemDataset(it) + '>' + I().esc(it.label) + '</button>';
|
||||
}
|
||||
|
||||
function positionFloatingPanel(menu) {
|
||||
var toggle = menu.querySelector('.soon-admin-action-menu__toggle');
|
||||
var panel = menu.querySelector('.soon-admin-action-menu__panel');
|
||||
if (!toggle || !panel) return;
|
||||
panel.classList.add('soon-admin-action-menu__panel--floating');
|
||||
panel.style.display = 'block';
|
||||
panel.style.visibility = 'hidden';
|
||||
var r = toggle.getBoundingClientRect();
|
||||
var panelW = panel.offsetWidth;
|
||||
var panelH = panel.offsetHeight;
|
||||
var gap = 4;
|
||||
var left = r.right - panelW;
|
||||
if (left < 8) left = 8;
|
||||
if (left + panelW > window.innerWidth - 8) left = window.innerWidth - panelW - 8;
|
||||
var top = r.bottom + gap;
|
||||
if (top + panelH > window.innerHeight - 8) {
|
||||
top = Math.max(8, r.top - panelH - gap);
|
||||
}
|
||||
panel.style.left = left + 'px';
|
||||
panel.style.top = top + 'px';
|
||||
panel.style.visibility = '';
|
||||
}
|
||||
|
||||
function actionMenu(menuId, items) {
|
||||
var btns = items.map(menuItemBtn).join('');
|
||||
return '<div class="soon-admin-action-menu" data-menu="' + I().esc(menuId) + '">' +
|
||||
'<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-admin-action-menu__toggle">操作 ▾</button>' +
|
||||
'<div class="soon-admin-action-menu__panel">' + btns + '</div></div>';
|
||||
}
|
||||
|
||||
function rowActions(menuId, inlineItems, moreItems) {
|
||||
var inline = (inlineItems || []).map(inlineBtn).join('');
|
||||
var more = (moreItems || []).map(menuItemBtn).join('');
|
||||
if (!more) {
|
||||
return '<div class="soon-admin-row-actions" data-menu="' + I().esc(menuId) + '">' + inline + '</div>';
|
||||
}
|
||||
return '<div class="soon-admin-row-actions" data-menu="' + I().esc(menuId) + '">' + inline +
|
||||
'<div class="soon-admin-action-menu soon-admin-action-menu--more">' +
|
||||
'<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-admin-action-menu__toggle">更多 ▾</button>' +
|
||||
'<div class="soon-admin-action-menu__panel">' + more + '</div></div></div>';
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
document.querySelectorAll('.soon-admin-action-menu.is-open').forEach(function (m) {
|
||||
m.classList.remove('is-open');
|
||||
resetPanelStyle(m.querySelector('.soon-admin-action-menu__panel'));
|
||||
});
|
||||
}
|
||||
|
||||
function bindActionButtons(container, handler, selector) {
|
||||
container.querySelectorAll(selector).forEach(function (btn) {
|
||||
if (btn.disabled) return;
|
||||
btn.onclick = function (e) {
|
||||
e.stopPropagation();
|
||||
closeAll();
|
||||
if (typeof handler === 'function') handler(btn.dataset.action, btn);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resetPanelStyle(panel) {
|
||||
if (!panel) return;
|
||||
panel.style.display = '';
|
||||
panel.style.left = '';
|
||||
panel.style.top = '';
|
||||
panel.style.visibility = '';
|
||||
}
|
||||
|
||||
function bindHoverMore(container) {
|
||||
container.querySelectorAll('.soon-admin-action-menu--more').forEach(function (menu) {
|
||||
var hideTimer = null;
|
||||
var panel = menu.querySelector('.soon-admin-action-menu__panel');
|
||||
function openMenu() {
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = null;
|
||||
}
|
||||
closeAll();
|
||||
positionFloatingPanel(menu);
|
||||
menu.classList.add('is-open');
|
||||
}
|
||||
function scheduleClose() {
|
||||
hideTimer = setTimeout(function () {
|
||||
menu.classList.remove('is-open');
|
||||
resetPanelStyle(panel);
|
||||
}, 180);
|
||||
}
|
||||
function cancelClose() {
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = null;
|
||||
}
|
||||
}
|
||||
menu.addEventListener('mouseenter', openMenu);
|
||||
menu.addEventListener('mouseleave', scheduleClose);
|
||||
if (panel) {
|
||||
panel.addEventListener('mouseenter', cancelClose);
|
||||
panel.addEventListener('mouseleave', scheduleClose);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bind(container, handler) {
|
||||
if (!container) return;
|
||||
bindActionButtons(container, handler, '.soon-admin-row-actions__btn[data-action]');
|
||||
container.querySelectorAll('.soon-admin-action-menu__toggle').forEach(function (btn) {
|
||||
var menu = btn.closest('.soon-admin-action-menu');
|
||||
if (menu && menu.classList.contains('soon-admin-action-menu--more')) return;
|
||||
btn.onclick = function (e) {
|
||||
e.stopPropagation();
|
||||
var wasOpen = menu.classList.contains('is-open');
|
||||
closeAll();
|
||||
if (!wasOpen) {
|
||||
positionFloatingPanel(menu);
|
||||
menu.classList.add('is-open');
|
||||
}
|
||||
};
|
||||
});
|
||||
bindActionButtons(container, handler, '.soon-admin-action-menu__item[data-action]');
|
||||
bindHoverMore(container);
|
||||
}
|
||||
|
||||
if (!window._soonAdminMenuDocBound) {
|
||||
window._soonAdminMenuDocBound = true;
|
||||
document.addEventListener('click', closeAll);
|
||||
window.addEventListener('scroll', closeAll, true);
|
||||
window.addEventListener('resize', closeAll);
|
||||
}
|
||||
|
||||
window.AdminMenu = {
|
||||
actionMenu: actionMenu,
|
||||
rowActions: rowActions,
|
||||
bind: bind,
|
||||
closeAll: closeAll,
|
||||
positionFloatingPanel: positionFloatingPanel,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,83 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
|
||||
function pagesCount(total, size) {
|
||||
return Math.max(1, Math.ceil((total || 0) / (size || 20)));
|
||||
}
|
||||
|
||||
function html(opts) {
|
||||
opts = opts || {};
|
||||
var page = opts.page || 1;
|
||||
var size = opts.size || 20;
|
||||
var total = opts.total || 0;
|
||||
var pages = pagesCount(total, size);
|
||||
var sizes = opts.sizeOptions || SIZE_OPTIONS;
|
||||
if (total === 0) {
|
||||
return '<div class="soon-admin-pager soon-admin-pager--empty"><span class="soon-admin-pager__info">共 0 条</span></div>';
|
||||
}
|
||||
var sizeOpts = sizes.map(function (n) {
|
||||
return '<option value="' + n + '"' + (n === size ? ' selected' : '') + '>' + n + ' 条/页</option>';
|
||||
}).join('');
|
||||
return '<div class="soon-admin-pager" data-page="' + page + '" data-size="' + size + '" data-total="' + total + '">' +
|
||||
'<div class="soon-admin-pager__left">' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost soon-pager-first"' +
|
||||
(page <= 1 ? ' disabled' : '') + ' title="首页">«</button>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost soon-pager-prev"' +
|
||||
(page <= 1 ? ' disabled' : '') + ' title="上一页">‹</button>' +
|
||||
'<span class="soon-admin-pager__info">第 <strong>' + page + '</strong> / ' + pages + ' 页,共 ' + total + ' 条</span>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost soon-pager-next"' +
|
||||
(page >= pages ? ' disabled' : '') + ' title="下一页">›</button>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost soon-pager-last"' +
|
||||
(page >= pages ? ' disabled' : '') + ' title="末页">»</button>' +
|
||||
'</div>' +
|
||||
'<div class="soon-admin-pager__right">' +
|
||||
'<label class="soon-admin-pager__size-label">每页<select class="soon-input soon-input--sm soon-pager-size">' +
|
||||
sizeOpts + '</select></label>' +
|
||||
'<label class="soon-admin-pager__jump-label">跳至<input type="number" class="soon-input soon-input--sm soon-pager-jump" min="1" max="' +
|
||||
pages + '" value="' + page + '">页<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost soon-pager-go">GO</button></label>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function bind(container, state, onChange) {
|
||||
if (!container) return;
|
||||
var page = state.page || 1;
|
||||
var size = state.size || 20;
|
||||
var total = state.total || 0;
|
||||
var pages = pagesCount(total, size);
|
||||
|
||||
function go(p) {
|
||||
p = Math.max(1, Math.min(pages, p));
|
||||
if (p !== page) onChange({ page: p });
|
||||
}
|
||||
|
||||
var first = container.querySelector('.soon-pager-first');
|
||||
var prev = container.querySelector('.soon-pager-prev');
|
||||
var next = container.querySelector('.soon-pager-next');
|
||||
var last = container.querySelector('.soon-pager-last');
|
||||
var sizeSel = container.querySelector('.soon-pager-size');
|
||||
var jump = container.querySelector('.soon-pager-jump');
|
||||
var goBtn = container.querySelector('.soon-pager-go');
|
||||
|
||||
if (first && !first.disabled) first.onclick = function () { go(1); };
|
||||
if (prev && !prev.disabled) prev.onclick = function () { go(page - 1); };
|
||||
if (next && !next.disabled) next.onclick = function () { go(page + 1); };
|
||||
if (last && !last.disabled) last.onclick = function () { go(pages); };
|
||||
if (sizeSel) {
|
||||
sizeSel.onchange = function () {
|
||||
onChange({ page: 1, size: parseInt(sizeSel.value, 10) || size });
|
||||
};
|
||||
}
|
||||
if (goBtn && jump) {
|
||||
goBtn.onclick = function () {
|
||||
go(parseInt(jump.value, 10) || page);
|
||||
};
|
||||
jump.onkeydown = function (e) {
|
||||
if (e.key === 'Enter') go(parseInt(jump.value, 10) || page);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
window.AdminPager = { html: html, bind: bind, pagesCount: pagesCount };
|
||||
})();
|
||||
@@ -0,0 +1,40 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var I = function () { return window.AdminI18n; };
|
||||
|
||||
function headerHtml(opts) {
|
||||
opts = opts || {};
|
||||
return '<header class="soon-admin-header" id="adminShellHeader">' +
|
||||
'<div class="soon-admin-header__brand">' +
|
||||
'<img class="soon-admin-logo" src="' + I().esc(opts.logoSrc || '../../assets/images/logo.png') + '" alt="">' +
|
||||
'<span>' + I().esc(opts.title || 'SoonDesign Admin') + '</span>' +
|
||||
(opts.env ? '<span class="soon-admin-header__env">' + I().esc(opts.env) + '</span>' : '') +
|
||||
'</div>' +
|
||||
'<div class="soon-admin-header__user">' +
|
||||
(opts.designEntry !== false
|
||||
? '<a href="' + I().esc(opts.designHref || '../index.web.html') +
|
||||
'" class="soon-btn soon-btn--sm" id="adminDesignEntry" target="_blank" rel="noopener">进入设计端</a>'
|
||||
: '') +
|
||||
'<span id="adminEmail" class="soon-admin-header__email"></span>' +
|
||||
'<button type="button" class="soon-btn soon-btn--ghost soon-btn--sm soon-admin-logout-btn" id="adminLogout">退出</button>' +
|
||||
'</div></header>';
|
||||
}
|
||||
|
||||
function mountHeader(container, opts) {
|
||||
if (!container) return;
|
||||
container.innerHTML = headerHtml(opts);
|
||||
}
|
||||
|
||||
function bindHeader(handlers) {
|
||||
handlers = handlers || {};
|
||||
var logout = document.getElementById('adminLogout');
|
||||
if (logout && handlers.onLogout) logout.onclick = handlers.onLogout;
|
||||
}
|
||||
|
||||
window.AdminShell = {
|
||||
headerHtml: headerHtml,
|
||||
mountHeader: mountHeader,
|
||||
bindHeader: bindHeader,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,40 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var I = function () { return window.AdminI18n; };
|
||||
|
||||
function html(id, tabs, panels, activeId) {
|
||||
activeId = activeId || (tabs[0] && tabs[0].id);
|
||||
var nav = tabs.map(function (t) {
|
||||
var cls = t.id === activeId ? ' soon-admin-tabs__tab is-active' : ' soon-admin-tabs__tab';
|
||||
return '<button type="button" class="' + cls.trim() + '" data-tab="' + I().esc(t.id) + '">' +
|
||||
I().esc(t.label) + '</button>';
|
||||
}).join('');
|
||||
var body = tabs.map(function (t) {
|
||||
var vis = t.id === activeId ? ' is-active' : '';
|
||||
return '<div class="soon-admin-tabs__panel' + vis + '" data-panel="' + I().esc(t.id) + '">' +
|
||||
(panels[t.id] || '') + '</div>';
|
||||
}).join('');
|
||||
return '<div class="soon-admin-tabs" id="' + I().esc(id) + '">' +
|
||||
'<nav class="soon-admin-tabs__nav" role="tablist">' + nav + '</nav>' +
|
||||
'<div class="soon-admin-tabs__body">' + body + '</div></div>';
|
||||
}
|
||||
|
||||
function bind(root, onChange) {
|
||||
if (!root) return;
|
||||
root.querySelectorAll('.soon-admin-tabs__tab').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
var tabId = btn.dataset.tab;
|
||||
root.querySelectorAll('.soon-admin-tabs__tab').forEach(function (b) {
|
||||
b.classList.toggle('is-active', b.dataset.tab === tabId);
|
||||
});
|
||||
root.querySelectorAll('.soon-admin-tabs__panel').forEach(function (p) {
|
||||
p.classList.toggle('is-active', p.dataset.panel === tabId);
|
||||
});
|
||||
if (typeof onChange === 'function') onChange(tabId);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
window.AdminTabs = { html: html, bind: bind };
|
||||
})();
|
||||
@@ -0,0 +1,265 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var TOKEN_KEY = 'soon_access';
|
||||
var REFRESH_KEY = 'soon_refresh';
|
||||
var refreshPromise = null;
|
||||
var scopeControllers = {};
|
||||
|
||||
function getAdminBase() {
|
||||
var cfg = window.SOON_DEPLOY_CONFIG || {};
|
||||
return (cfg.api_admin_base || '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getV1Base() {
|
||||
var cfg = window.SOON_DEPLOY_CONFIG || {};
|
||||
return (cfg.api_v1_base || '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY) || '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getRefreshToken() {
|
||||
try {
|
||||
return localStorage.getItem(REFRESH_KEY) || '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function loginRedirectUrl() {
|
||||
var hash = location.hash || '';
|
||||
var redirect = 'index.html' + hash;
|
||||
return 'login.html?redirect=' + encodeURIComponent(redirect);
|
||||
}
|
||||
|
||||
function goLogin() {
|
||||
window.location.href = loginRedirectUrl();
|
||||
}
|
||||
|
||||
function apiUrl(path) {
|
||||
var p = String(path || '').replace(/^\//, '');
|
||||
return getAdminBase() + '/' + p;
|
||||
}
|
||||
|
||||
function defaultScope() {
|
||||
if (window.AdminRouter && typeof AdminRouter.currentTab === 'function') {
|
||||
return AdminRouter.currentTab() || 'app';
|
||||
}
|
||||
return 'app';
|
||||
}
|
||||
|
||||
function abortScope(scope) {
|
||||
if (!scope) return;
|
||||
var ctrl = scopeControllers[scope];
|
||||
if (!ctrl) return;
|
||||
ctrl.abort();
|
||||
delete scopeControllers[scope];
|
||||
}
|
||||
|
||||
function attachScope(scope) {
|
||||
abortScope(scope);
|
||||
var ctrl = new AbortController();
|
||||
scopeControllers[scope] = ctrl;
|
||||
return ctrl.signal;
|
||||
}
|
||||
|
||||
function isAbortError(err) {
|
||||
return err && (err.name === 'AbortError' || err.code === 20);
|
||||
}
|
||||
|
||||
function refreshAccessToken() {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
var refresh = getRefreshToken();
|
||||
if (!refresh) {
|
||||
return Promise.reject(new Error('no_refresh_token'));
|
||||
}
|
||||
refreshPromise = window.fetch(getV1Base() + '/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refresh }),
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (j) {
|
||||
if (!r.ok || !j.ok || !j.data || !j.data.access_token) {
|
||||
throw new Error(j.message || 'refresh_failed');
|
||||
}
|
||||
localStorage.setItem(TOKEN_KEY, j.data.access_token);
|
||||
if (j.data.refresh_token) {
|
||||
localStorage.setItem(REFRESH_KEY, j.data.refresh_token);
|
||||
}
|
||||
return j.data.access_token;
|
||||
});
|
||||
}).finally(function () {
|
||||
refreshPromise = null;
|
||||
});
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
function sessionExpired() {
|
||||
clearToken();
|
||||
if (typeof soonToast === 'function') soonToast('登录已过期,请重新登录', 'warn');
|
||||
setTimeout(goLogin, 600);
|
||||
}
|
||||
|
||||
async function readJsonResponse(r, signal) {
|
||||
if (signal && signal.aborted) return null;
|
||||
var j;
|
||||
try {
|
||||
j = await r.json();
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return null;
|
||||
if (typeof soonToast === 'function') soonToast('响应解析失败', 'error');
|
||||
return null;
|
||||
}
|
||||
if (signal && signal.aborted) return null;
|
||||
return j;
|
||||
}
|
||||
|
||||
async function httpRequest(url, opts) {
|
||||
opts = opts || {};
|
||||
var signal = opts.signal;
|
||||
var headers = Object.assign({
|
||||
Authorization: 'Bearer ' + getToken(),
|
||||
'Content-Type': 'application/json',
|
||||
}, opts.headers || {});
|
||||
var reqOpts = Object.assign({}, opts, { headers: headers, signal: signal });
|
||||
delete reqOpts.scope;
|
||||
delete reqOpts.retry401;
|
||||
|
||||
try {
|
||||
return await window.fetch(url, reqOpts);
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestWithAuth(url, opts) {
|
||||
opts = opts || {};
|
||||
var signal = opts.signal;
|
||||
var r = await httpRequest(url, opts);
|
||||
if (!r) return null;
|
||||
|
||||
if (r.status === 401 && !opts.retry401) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
} catch (e) {
|
||||
sessionExpired();
|
||||
return null;
|
||||
}
|
||||
var retryOpts = Object.assign({}, opts, { retry401: true });
|
||||
retryOpts.headers = Object.assign({}, opts.headers || {}, {
|
||||
Authorization: 'Bearer ' + getToken(),
|
||||
});
|
||||
r = await httpRequest(url, retryOpts);
|
||||
if (!r) return null;
|
||||
if (r.status === 401) {
|
||||
sessionExpired();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (r.status === 403) {
|
||||
var forbidden = await readJsonResponse(r, signal);
|
||||
if (forbidden && typeof soonToast === 'function') {
|
||||
soonToast(forbidden.message || '无权限执行此操作', 'warn');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
async function fetch(path, opts) {
|
||||
opts = opts || {};
|
||||
var scope = opts.scope || defaultScope();
|
||||
var signal = opts.signal || attachScope(scope);
|
||||
var r;
|
||||
try {
|
||||
r = await requestWithAuth(apiUrl(path), Object.assign({}, opts, { signal: signal }));
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return null;
|
||||
if (typeof soonToast === 'function') soonToast('网络错误,请稍后重试', 'error');
|
||||
return null;
|
||||
}
|
||||
if (!r || (signal && signal.aborted)) return null;
|
||||
|
||||
var j = await readJsonResponse(r, signal);
|
||||
if (!j || (signal && signal.aborted)) return null;
|
||||
if (!j.ok) {
|
||||
if (typeof soonToast === 'function') soonToast(j.message || '操作失败', 'error');
|
||||
return null;
|
||||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
async function get(path, opts) {
|
||||
return fetch(path, Object.assign({}, opts || {}, { method: 'GET' }));
|
||||
}
|
||||
|
||||
async function postJson(path, body, opts) {
|
||||
return fetch(path, Object.assign({}, opts || {}, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body || {}),
|
||||
}));
|
||||
}
|
||||
|
||||
async function putJson(path, body, opts) {
|
||||
return fetch(path, Object.assign({}, opts || {}, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body || {}),
|
||||
}));
|
||||
}
|
||||
|
||||
async function del(path, opts) {
|
||||
return fetch(path, Object.assign({}, opts || {}, { method: 'DELETE' }));
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
if (!getToken()) return null;
|
||||
var signal = attachScope('auth');
|
||||
var r;
|
||||
try {
|
||||
r = await requestWithAuth(getV1Base() + '/auth/me', {
|
||||
method: 'GET',
|
||||
signal: signal,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return null;
|
||||
return null;
|
||||
}
|
||||
if (!r || signal.aborted) return null;
|
||||
var j = await readJsonResponse(r, signal);
|
||||
if (!j || signal.aborted) return null;
|
||||
if (!j.ok || !j.data) return null;
|
||||
return j.data;
|
||||
}
|
||||
|
||||
window.AdminApi = {
|
||||
getToken: getToken,
|
||||
clearToken: clearToken,
|
||||
goLogin: goLogin,
|
||||
getV1Base: getV1Base,
|
||||
fetch: fetch,
|
||||
get: get,
|
||||
postJson: postJson,
|
||||
putJson: putJson,
|
||||
del: del,
|
||||
fetchMe: fetchMe,
|
||||
abortScope: abortScope,
|
||||
refreshAccessToken: refreshAccessToken,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,124 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STATUS = {
|
||||
active: '正常',
|
||||
disabled: '已停用',
|
||||
pending: '待支付',
|
||||
paid: '已支付',
|
||||
cancelled: '已取消',
|
||||
refunded: '已退款',
|
||||
expired: '已过期',
|
||||
};
|
||||
|
||||
var REFUND_STATUS = {
|
||||
none: '无申请',
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
};
|
||||
|
||||
var ROLE = {
|
||||
user: '普通用户',
|
||||
admin: '管理员',
|
||||
};
|
||||
|
||||
var CHANNEL = {
|
||||
alipay: '支付宝',
|
||||
wechat: '微信支付',
|
||||
};
|
||||
|
||||
var ACTION = {
|
||||
'stats.summary': '查看概览',
|
||||
'users.list': '浏览用户列表',
|
||||
'users.create': '创建用户',
|
||||
'users.update': '编辑用户',
|
||||
'users.delete': '删除用户',
|
||||
'users.detail': '查看用户详情',
|
||||
'users.setPassword': '重置用户密码',
|
||||
'users.setSubscription': '设置用户会员',
|
||||
'users.setStatus': '变更用户状态',
|
||||
'orders.list': '浏览订单列表',
|
||||
'orders.detail': '查看订单详情',
|
||||
'orders.cancel': '取消订单',
|
||||
'orders.refundApprove': '通过退款',
|
||||
'orders.refundReject': '拒绝退款',
|
||||
'plans.list': '浏览套餐列表',
|
||||
'plans.upsert': '编辑套餐',
|
||||
'settings.list': '浏览系统设置',
|
||||
'settings.set': '修改系统设置',
|
||||
'settings.set_batch': '批量修改系统设置',
|
||||
'payment.key.upload': '上传支付密钥',
|
||||
'payment.key.status': '查看支付密钥',
|
||||
};
|
||||
|
||||
var STATUS_BADGE = {
|
||||
active: 'soon-badge--active',
|
||||
disabled: 'soon-badge--disabled',
|
||||
pending: 'soon-badge--pending',
|
||||
paid: 'soon-badge--paid',
|
||||
cancelled: 'soon-badge--disabled',
|
||||
refunded: 'soon-badge--pending',
|
||||
expired: 'soon-badge--disabled',
|
||||
};
|
||||
|
||||
var REFUND_BADGE = {
|
||||
none: 'soon-badge--disabled',
|
||||
pending: 'soon-badge--pending',
|
||||
approved: 'soon-badge--paid',
|
||||
rejected: 'soon-badge--disabled',
|
||||
};
|
||||
|
||||
function t(map, v) {
|
||||
if (v == null || v === '') return '-';
|
||||
return map[v] || String(v);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
if (typeof soonEscapeHtml === 'function') return soonEscapeHtml(s);
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function badgeStatus(v) {
|
||||
var cls = STATUS_BADGE[v] || 'soon-badge--pending';
|
||||
return '<span class="soon-badge ' + cls + '">' + esc(t(STATUS, v)) + '</span>';
|
||||
}
|
||||
|
||||
function badgeRefundStatus(v) {
|
||||
var key = v || 'none';
|
||||
var cls = REFUND_BADGE[key] || 'soon-badge--disabled';
|
||||
return '<span class="soon-badge ' + cls + '">' + esc(t(REFUND_STATUS, key)) + '</span>';
|
||||
}
|
||||
|
||||
function formatCents(n) {
|
||||
var c = parseInt(n, 10) || 0;
|
||||
return '¥' + (c / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
var b = parseInt(n, 10) || 0;
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB';
|
||||
return (b / (1024 * 1024)).toFixed(2) + ' MB';
|
||||
}
|
||||
|
||||
function formatDateTime(s) {
|
||||
if (!s) return '-';
|
||||
return String(s).replace('T', ' ').slice(0, 19);
|
||||
}
|
||||
|
||||
window.AdminI18n = {
|
||||
tStatus: function (v) { return t(STATUS, v); },
|
||||
tRole: function (v) { return t(ROLE, v); },
|
||||
tChannel: function (v) { return t(CHANNEL, v); },
|
||||
tAction: function (v) { return t(ACTION, v); },
|
||||
badgeStatus: badgeStatus,
|
||||
badgeRefundStatus: badgeRefundStatus,
|
||||
tRefundStatus: function (v) { return t(REFUND_STATUS, v || 'none'); },
|
||||
formatCents: formatCents,
|
||||
formatBytes: formatBytes,
|
||||
formatDateTime: formatDateTime,
|
||||
esc: esc,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,44 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var FULL_ONLY = { settings: true, audits: true, payment: true };
|
||||
|
||||
function normalizeLevel(level) {
|
||||
return level === 'ops' ? 'ops' : 'full';
|
||||
}
|
||||
|
||||
function canAccessTab(tab, level) {
|
||||
level = normalizeLevel(level);
|
||||
if (level === 'full') return true;
|
||||
return !FULL_ONLY[tab];
|
||||
}
|
||||
|
||||
function defaultTab(level) {
|
||||
return 'dashboard';
|
||||
}
|
||||
|
||||
function applyNav(level) {
|
||||
level = normalizeLevel(level);
|
||||
document.querySelectorAll('.soon-admin-nav-item').forEach(function (el) {
|
||||
var req = el.getAttribute('data-require') || 'ops';
|
||||
var show = req !== 'full' || level === 'full';
|
||||
el.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function filterQuickLinks(links, level) {
|
||||
level = normalizeLevel(level);
|
||||
return links.filter(function (l) {
|
||||
var tab = (l.href || '').replace(/^#\//, '').split(/[/?]/)[0];
|
||||
return canAccessTab(tab, level);
|
||||
});
|
||||
}
|
||||
|
||||
window.AdminPermissions = {
|
||||
canAccessTab: canAccessTab,
|
||||
defaultTab: defaultTab,
|
||||
applyNav: applyNav,
|
||||
filterQuickLinks: filterQuickLinks,
|
||||
normalizeLevel: normalizeLevel,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,297 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var State = function () { return window.AdminState; };
|
||||
var Perms = function () { return window.AdminPermissions; };
|
||||
|
||||
var ROUTES = {
|
||||
'': 'dashboard',
|
||||
'/': 'dashboard',
|
||||
'/dashboard': 'dashboard',
|
||||
'/users': 'users',
|
||||
'/orders': 'orders',
|
||||
'/plans': 'plans',
|
||||
'/settings': 'settings',
|
||||
'/audits': 'audits',
|
||||
'/payment': 'payment',
|
||||
};
|
||||
|
||||
var TAB_TO_PATH = {
|
||||
dashboard: '/dashboard',
|
||||
users: '/users',
|
||||
orders: '/orders',
|
||||
plans: '/plans',
|
||||
settings: '/settings',
|
||||
audits: '/audits',
|
||||
payment: '/payment',
|
||||
};
|
||||
|
||||
var DETAIL_TABS = { users: true, orders: true };
|
||||
|
||||
var FILTER_DEFS = {
|
||||
users: {
|
||||
stateKey: 'users',
|
||||
defaults: { page: 1, size: 20, q: '' },
|
||||
fields: {
|
||||
q: 'string',
|
||||
page: 'int',
|
||||
size: 'int',
|
||||
},
|
||||
},
|
||||
orders: {
|
||||
stateKey: 'orders',
|
||||
defaults: { page: 1, size: 20, q: '', status: '', refund_status: '', channel: '', from: '', to: '' },
|
||||
fields: {
|
||||
q: 'string',
|
||||
page: 'int',
|
||||
size: 'int',
|
||||
status: 'string',
|
||||
refund_status: 'string',
|
||||
channel: 'string',
|
||||
from: 'string',
|
||||
to: 'string',
|
||||
},
|
||||
},
|
||||
audits: {
|
||||
stateKey: 'audits',
|
||||
defaults: { page: 1, size: 50, q: '', from: '', to: '', action: '' },
|
||||
fields: {
|
||||
q: 'string',
|
||||
page: 'int',
|
||||
size: 'int',
|
||||
from: 'string',
|
||||
to: 'string',
|
||||
action: 'string',
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
stateKey: 'settings',
|
||||
defaults: { tab: 'site', q: '' },
|
||||
fields: {
|
||||
tab: 'string',
|
||||
q: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var handlers = {};
|
||||
var currentTab = null;
|
||||
var pendingDetailId = null;
|
||||
var suppressHash = false;
|
||||
|
||||
function parseQuery(qs) {
|
||||
var out = {};
|
||||
if (!qs) return out;
|
||||
qs.split('&').forEach(function (pair) {
|
||||
if (!pair) return;
|
||||
var idx = pair.indexOf('=');
|
||||
var k = idx >= 0 ? pair.slice(0, idx) : pair;
|
||||
var v = idx >= 0 ? pair.slice(idx + 1) : '';
|
||||
try {
|
||||
k = decodeURIComponent(k.replace(/\+/g, ' '));
|
||||
v = decodeURIComponent(v.replace(/\+/g, ' '));
|
||||
} catch (e) { /* keep raw */ }
|
||||
if (k) out[k] = v;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseHash() {
|
||||
var raw = (location.hash || '#/dashboard').replace(/^#/, '');
|
||||
if (!raw.startsWith('/')) raw = '/' + raw;
|
||||
|
||||
var qIdx = raw.indexOf('?');
|
||||
var pathStr = qIdx >= 0 ? raw.substring(0, qIdx) : raw;
|
||||
var query = parseQuery(qIdx >= 0 ? raw.substring(qIdx + 1) : '');
|
||||
|
||||
var segments = pathStr.split('/').filter(function (s) { return s; });
|
||||
var head = segments[0] || 'dashboard';
|
||||
var tab = ROUTES['/' + head] || 'dashboard';
|
||||
var id = null;
|
||||
if (segments[1] && DETAIL_TABS[tab]) {
|
||||
var n = parseInt(segments[1], 10);
|
||||
if (!isNaN(n) && n > 0) id = n;
|
||||
}
|
||||
|
||||
return { tab: tab, id: id, query: query };
|
||||
}
|
||||
|
||||
function coerceField(type, val) {
|
||||
if (type === 'int') {
|
||||
var n = parseInt(val, 10);
|
||||
return isNaN(n) ? null : n;
|
||||
}
|
||||
return val == null ? '' : String(val);
|
||||
}
|
||||
|
||||
function applyUrlToState(loc) {
|
||||
loc = loc || parseHash();
|
||||
Object.keys(FILTER_DEFS).forEach(function (tab) {
|
||||
if (loc.tab !== tab) return;
|
||||
var def = FILTER_DEFS[tab];
|
||||
var st = State()[def.stateKey];
|
||||
if (!st) return;
|
||||
Object.keys(def.defaults).forEach(function (key) {
|
||||
st[key] = def.defaults[key];
|
||||
});
|
||||
Object.keys(def.fields).forEach(function (key) {
|
||||
if (!(key in loc.query)) return;
|
||||
var val = coerceField(def.fields[key], loc.query[key]);
|
||||
if (val == null) return;
|
||||
if (def.fields[key] === 'int' && val <= 0) return;
|
||||
st[key] = val;
|
||||
});
|
||||
if (tab === 'settings' && st.tab !== 'site' && st.tab !== 'all') {
|
||||
st.tab = 'site';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function shouldIncludeParam(key, val, defVal) {
|
||||
if (val == null || val === '') return false;
|
||||
if (typeof defVal === 'number') return val !== defVal;
|
||||
return String(val) !== String(defVal);
|
||||
}
|
||||
|
||||
function buildHash(tab, opts) {
|
||||
opts = opts || {};
|
||||
var path = TAB_TO_PATH[tab] || '/dashboard';
|
||||
if (opts.id && DETAIL_TABS[tab]) {
|
||||
path += '/' + opts.id;
|
||||
}
|
||||
|
||||
var def = FILTER_DEFS[tab];
|
||||
var qs = '';
|
||||
if (def) {
|
||||
var st = State()[def.stateKey] || {};
|
||||
var parts = [];
|
||||
Object.keys(def.fields).forEach(function (key) {
|
||||
var val = st[key];
|
||||
if (shouldIncludeParam(key, val, def.defaults[key])) {
|
||||
parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(String(val)));
|
||||
}
|
||||
});
|
||||
if (parts.length) qs = '?' + parts.join('&');
|
||||
}
|
||||
|
||||
return '#' + path + qs;
|
||||
}
|
||||
|
||||
function setHash(hash) {
|
||||
if (location.hash === hash) return;
|
||||
suppressHash = true;
|
||||
location.hash = hash;
|
||||
suppressHash = false;
|
||||
}
|
||||
|
||||
function showTab(name, loc) {
|
||||
var level = State().adminLevel || 'full';
|
||||
if (Perms() && !Perms().canAccessTab(name, level)) {
|
||||
name = Perms().defaultTab(level);
|
||||
var safeHash = buildHash(name);
|
||||
if (location.hash !== safeHash) {
|
||||
setHash(safeHash);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!handlers[name]) name = 'dashboard';
|
||||
var prevTab = currentTab;
|
||||
if (prevTab && prevTab !== name && window.AdminApi && typeof AdminApi.abortScope === 'function') {
|
||||
AdminApi.abortScope(prevTab);
|
||||
}
|
||||
currentTab = name;
|
||||
pendingDetailId = loc && loc.id ? loc.id : null;
|
||||
|
||||
document.querySelectorAll('.panel').forEach(function (p) {
|
||||
p.classList.remove('is-visible');
|
||||
});
|
||||
var panel = document.getElementById('panel-' + name);
|
||||
if (panel) panel.classList.add('is-visible');
|
||||
document.querySelectorAll('.soon-admin-nav-item').forEach(function (li) {
|
||||
li.classList.toggle('is-active', li.dataset.tab === name);
|
||||
});
|
||||
if (handlers[name] && typeof handlers[name].render === 'function') {
|
||||
handlers[name].render();
|
||||
}
|
||||
}
|
||||
|
||||
function onHashChange() {
|
||||
if (suppressHash) return;
|
||||
var loc = parseHash();
|
||||
applyUrlToState(loc);
|
||||
showTab(loc.tab, loc);
|
||||
}
|
||||
|
||||
function navigate(tab) {
|
||||
pendingDetailId = null;
|
||||
var hash = buildHash(tab);
|
||||
if (location.hash === hash) {
|
||||
showTab(tab, parseHash());
|
||||
} else {
|
||||
setHash(hash);
|
||||
}
|
||||
}
|
||||
|
||||
function syncQuery(opts) {
|
||||
if (!currentTab) return;
|
||||
var hash = buildHash(currentTab, opts || {});
|
||||
setHash(hash);
|
||||
}
|
||||
|
||||
function openDetail(id) {
|
||||
if (!currentTab || !DETAIL_TABS[currentTab]) return;
|
||||
var n = parseInt(id, 10);
|
||||
if (isNaN(n) || n <= 0) return;
|
||||
setHash(buildHash(currentTab, { id: n }));
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
if (!currentTab || !DETAIL_TABS[currentTab]) return;
|
||||
var loc = parseHash();
|
||||
if (!loc.id) return;
|
||||
pendingDetailId = null;
|
||||
setHash(buildHash(currentTab));
|
||||
}
|
||||
|
||||
function hasPendingDetail() {
|
||||
return pendingDetailId != null;
|
||||
}
|
||||
|
||||
function consumeDetailId() {
|
||||
var id = pendingDetailId;
|
||||
pendingDetailId = null;
|
||||
return id;
|
||||
}
|
||||
|
||||
function onDrawerClosed() {
|
||||
closeDetail();
|
||||
}
|
||||
|
||||
function init(viewHandlers) {
|
||||
handlers = viewHandlers || {};
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
document.querySelectorAll('.soon-admin-nav-item').forEach(function (li) {
|
||||
li.onclick = function () {
|
||||
navigate(li.dataset.tab);
|
||||
};
|
||||
});
|
||||
if (!location.hash) {
|
||||
setHash('#/dashboard');
|
||||
}
|
||||
var loc = parseHash();
|
||||
applyUrlToState(loc);
|
||||
showTab(loc.tab, loc);
|
||||
}
|
||||
|
||||
window.AdminRouter = {
|
||||
init: init,
|
||||
navigate: navigate,
|
||||
syncQuery: syncQuery,
|
||||
openDetail: openDetail,
|
||||
closeDetail: closeDetail,
|
||||
hasPendingDetail: hasPendingDetail,
|
||||
consumeDetailId: consumeDetailId,
|
||||
onDrawerClosed: onDrawerClosed,
|
||||
currentTab: function () { return currentTab; },
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,14 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// 分页与 URL 状态:docs/API-PAGINATION.md、AdminRouter
|
||||
|
||||
window.AdminState = {
|
||||
currentAdminId: null,
|
||||
adminLevel: 'full',
|
||||
users: { page: 1, size: 20, q: '' },
|
||||
orders: { page: 1, size: 20, q: '', status: '', refund_status: '', channel: '', from: '', to: '' },
|
||||
audits: { page: 1, size: 50, q: '', from: '', to: '', action: '' },
|
||||
settings: { tab: 'site', q: '' },
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,246 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var I = function () { return window.AdminI18n; };
|
||||
|
||||
function pageHeader(title, desc, actionsHtml) {
|
||||
return '<div class="soon-admin-page-header">' +
|
||||
'<div class="soon-admin-page-header__main">' +
|
||||
'<h1>' + I().esc(title) + '</h1>' +
|
||||
(desc ? '<p class="soon-admin-page-header__desc">' + I().esc(desc) + '</p>' : '') +
|
||||
'</div>' +
|
||||
(actionsHtml ? '<div class="soon-admin-page-header__actions">' + actionsHtml + '</div>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function dataTable(headers, rowsHtml, colSpan) {
|
||||
var th = headers.map(function (h) { return '<th>' + h + '</th>'; }).join('');
|
||||
var empty = '<tr><td colspan="' + (colSpan || headers.length) + '" class="soon-admin-empty-row">' +
|
||||
emptyBlock('暂无数据', '', '') + '</td></tr>';
|
||||
return '<div class="soon-admin-table-wrap"><table class="soon-admin-table"><thead><tr>' + th +
|
||||
'</tr></thead><tbody>' + (rowsHtml || empty) + '</tbody></table></div>';
|
||||
}
|
||||
|
||||
function emptyBlock(title, hint, actionHtml) {
|
||||
var html = '<div class="soon-empty soon-empty--inline">' +
|
||||
'<img src="../../assets/images/web/admin/empty.svg" alt="" class="soon-admin-empty-icon">' +
|
||||
'<p class="soon-empty__title">' + I().esc(title) + '</p>';
|
||||
if (hint) html += '<p class="soon-empty__hint">' + I().esc(hint) + '</p>';
|
||||
if (actionHtml) html += actionHtml;
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function skeletonStats(n) {
|
||||
var c = n || 4;
|
||||
var html = '<div class="soon-admin-stat-grid">';
|
||||
for (var i = 0; i < c; i++) {
|
||||
html += '<div class="soon-skeleton soon-skeleton--h80"></div>';
|
||||
}
|
||||
return html + '</div>';
|
||||
}
|
||||
|
||||
function skeletonTable() {
|
||||
return '<div class="soon-skeleton soon-skeleton--h120"></div>';
|
||||
}
|
||||
|
||||
function pagerHtml(page, size, total) {
|
||||
if (window.AdminPager) {
|
||||
return AdminPager.html({ page: page, size: size, total: total });
|
||||
}
|
||||
page = page || 1;
|
||||
size = size || 20;
|
||||
total = total || 0;
|
||||
var pages = Math.max(1, Math.ceil(total / size));
|
||||
return '<div class="soon-admin-pager">' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost soon-pager-prev"' +
|
||||
(page <= 1 ? ' disabled' : '') + '>上一页</button>' +
|
||||
'<span class="soon-admin-pager__info">第 ' + page + ' / ' + pages + ' 页(共 ' + total + ' 条)</span>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost soon-pager-next"' +
|
||||
(page >= pages ? ' disabled' : '') + '>下一页</button></div>';
|
||||
}
|
||||
|
||||
function bindPager(container, page, size, total, onPageChange) {
|
||||
if (!container) return;
|
||||
if (window.AdminPager) {
|
||||
AdminPager.bind(container, { page: page, size: size, total: total }, function (chg) {
|
||||
if (chg.size && chg.size !== size) {
|
||||
onPageChange(1, chg.size);
|
||||
} else if (chg.page) {
|
||||
onPageChange(chg.page, size);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
var prev = container.querySelector('.soon-pager-prev');
|
||||
var next = container.querySelector('.soon-pager-next');
|
||||
if (prev && !prev.disabled) {
|
||||
prev.onclick = function () { onPageChange(Math.max(1, page - 1)); };
|
||||
}
|
||||
if (next && !next.disabled) {
|
||||
next.onclick = function () {
|
||||
var pages = Math.max(1, Math.ceil(total / size));
|
||||
onPageChange(Math.min(pages, page + 1));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function confirm(msg, onOk) {
|
||||
if (typeof layer === 'undefined') {
|
||||
if (window.confirm(msg)) onOk();
|
||||
return;
|
||||
}
|
||||
layer.confirm(msg, { skin: 'soon-layer', title: '确认' }, function (idx) {
|
||||
layer.close(idx);
|
||||
onOk();
|
||||
});
|
||||
}
|
||||
|
||||
function openDrawer(title, bodyHtml) {
|
||||
closeDrawer();
|
||||
var overlay = document.createElement('div');
|
||||
overlay.className = 'soon-admin-drawer-overlay';
|
||||
overlay.id = 'adminDrawerOverlay';
|
||||
overlay.onclick = closeDrawer;
|
||||
|
||||
var drawer = document.createElement('div');
|
||||
drawer.className = 'soon-admin-drawer';
|
||||
drawer.id = 'adminDrawer';
|
||||
drawer.innerHTML =
|
||||
'<div class="soon-admin-drawer__header">' +
|
||||
'<h3>' + I().esc(title) + '</h3>' +
|
||||
'<button type="button" class="soon-admin-drawer__close" aria-label="关闭">×</button>' +
|
||||
'</div><div class="soon-admin-drawer__body">' + bodyHtml + '</div>';
|
||||
|
||||
drawer.querySelector('.soon-admin-drawer__close').onclick = closeDrawer;
|
||||
document.body.appendChild(overlay);
|
||||
document.body.appendChild(drawer);
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
var o = document.getElementById('adminDrawerOverlay');
|
||||
var d = document.getElementById('adminDrawer');
|
||||
if (o) o.remove();
|
||||
if (d) d.remove();
|
||||
if (window.AdminRouter && typeof AdminRouter.onDrawerClosed === 'function') {
|
||||
AdminRouter.onDrawerClosed();
|
||||
}
|
||||
}
|
||||
|
||||
function runFormSubmit(btn, handler, opts) {
|
||||
opts = opts || {};
|
||||
if (!btn || btn.disabled) return;
|
||||
var ret;
|
||||
try {
|
||||
ret = handler();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
if (ret === false) return;
|
||||
if (ret && typeof ret.then === 'function') {
|
||||
setBtnLoading(btn, true, opts.loadingLabel);
|
||||
ret.finally(function () {
|
||||
setBtnLoading(btn, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openFormModal(title, formHtml, onSave, opts) {
|
||||
if (typeof layer === 'undefined') return;
|
||||
opts = opts || {};
|
||||
layer.open({
|
||||
type: 1,
|
||||
skin: 'soon-layer',
|
||||
title: title,
|
||||
area: opts.area || ['420px', 'auto'],
|
||||
maxWidth: opts.maxWidth || '96vw',
|
||||
content: formHtml,
|
||||
success: function (layero) {
|
||||
var area = opts.area || ['420px', 'auto'];
|
||||
if (area[1] && area[1] !== 'auto') {
|
||||
var contentEl = null;
|
||||
if (layero && typeof layero.find === 'function') {
|
||||
contentEl = layero.find('.layui-layer-content')[0];
|
||||
} else if (layero && layero.querySelector) {
|
||||
contentEl = layero.querySelector('.layui-layer-content');
|
||||
}
|
||||
if (contentEl) contentEl.classList.add('soon-layer-content--scroll');
|
||||
}
|
||||
var btn = document.getElementById('adminModalSave');
|
||||
if (btn) {
|
||||
btn.onclick = function () {
|
||||
runFormSubmit(btn, onSave, opts);
|
||||
};
|
||||
}
|
||||
if (typeof opts.success === 'function') opts.success();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function bindFormSubmit(btnOrId, handler, loadingLabel) {
|
||||
var btn = typeof btnOrId === 'string' ? document.getElementById(btnOrId) : btnOrId;
|
||||
if (!btn) return;
|
||||
btn.onclick = function () {
|
||||
runFormSubmit(btn, handler, { loadingLabel: loadingLabel });
|
||||
};
|
||||
}
|
||||
|
||||
function detailSection(title, rowsHtml) {
|
||||
return '<div class="soon-admin-detail-section"><h4>' + I().esc(title) + '</h4>' + rowsHtml + '</div>';
|
||||
}
|
||||
|
||||
function detailRow(label, value) {
|
||||
return '<div class="soon-admin-detail-row"><span class="soon-admin-detail-row__label">' +
|
||||
I().esc(label) + '</span><span class="soon-admin-detail-row__value">' + value + '</span></div>';
|
||||
}
|
||||
|
||||
function filterDateField(id, label, value) {
|
||||
return '<div class="soon-admin-filter-field">' +
|
||||
'<span class="soon-admin-filter-field__label">' + I().esc(label) + '</span>' +
|
||||
'<span class="soon-admin-date-wrap">' +
|
||||
'<input type="date" id="' + id + '" class="soon-input soon-input--sm soon-input--date" value="' + I().esc(value || '') + '">' +
|
||||
'</span></div>';
|
||||
}
|
||||
|
||||
function listError(message, retryBtnId) {
|
||||
return '<div class="soon-admin-list-error">' +
|
||||
'<p>' + I().esc(message || '加载失败') + '</p>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="' + (retryBtnId || 'listRetry') + '">重试</button></div>';
|
||||
}
|
||||
|
||||
function setBtnLoading(btn, loading, label) {
|
||||
if (!btn) return;
|
||||
if (loading) {
|
||||
if (!btn.dataset.soonLabel) btn.dataset.soonLabel = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = label || '处理中…';
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
if (btn.dataset.soonLabel) btn.textContent = btn.dataset.soonLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
window.AdminUi = {
|
||||
pageHeader: pageHeader,
|
||||
dataTable: dataTable,
|
||||
emptyBlock: emptyBlock,
|
||||
skeletonStats: skeletonStats,
|
||||
skeletonTable: skeletonTable,
|
||||
pagerHtml: pagerHtml,
|
||||
bindPager: bindPager,
|
||||
confirm: confirm,
|
||||
openDrawer: openDrawer,
|
||||
closeDrawer: closeDrawer,
|
||||
openFormModal: openFormModal,
|
||||
runFormSubmit: runFormSubmit,
|
||||
bindFormSubmit: bindFormSubmit,
|
||||
detailSection: detailSection,
|
||||
detailRow: detailRow,
|
||||
filterDateField: filterDateField,
|
||||
listError: listError,
|
||||
setBtnLoading: setBtnLoading,
|
||||
el: el,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,116 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var TOKEN_KEY = 'soon_access';
|
||||
|
||||
function getV1Base() {
|
||||
var cfg = window.SOON_DEPLOY_CONFIG || {};
|
||||
return (cfg.api_v1_base || '/api/v1').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function redirectTarget() {
|
||||
var p = new URLSearchParams(location.search);
|
||||
var r = p.get('redirect');
|
||||
if (r && typeof soonSafeRedirect === 'function') {
|
||||
return soonSafeRedirect(r, 'index.html');
|
||||
}
|
||||
return r || 'index.html';
|
||||
}
|
||||
|
||||
function clearTokens() {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem('soon_refresh');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
var err = document.getElementById('loginError');
|
||||
if (err) {
|
||||
err.textContent = msg;
|
||||
err.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAdmin(token) {
|
||||
var r = await fetch(getV1Base() + '/auth/me', {
|
||||
headers: { Authorization: 'Bearer ' + token },
|
||||
});
|
||||
var j = await r.json();
|
||||
if (!j.ok || !j.data || j.data.role !== 'admin') return null;
|
||||
return j.data;
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (localStorage.getItem(TOKEN_KEY)) {
|
||||
verifyAdmin(localStorage.getItem(TOKEN_KEY)).then(function (me) {
|
||||
if (me) location.href = redirectTarget();
|
||||
});
|
||||
}
|
||||
|
||||
var pwdToggle = document.getElementById('pwdToggle');
|
||||
if (pwdToggle) {
|
||||
pwdToggle.onclick = function () {
|
||||
var p = document.getElementById('password');
|
||||
var show = p.type === 'password';
|
||||
p.type = show ? 'text' : 'password';
|
||||
this.textContent = show ? '隐藏' : '显示';
|
||||
};
|
||||
}
|
||||
|
||||
var form = document.getElementById('loginForm');
|
||||
if (!form) return;
|
||||
|
||||
form.onsubmit = function (e) {
|
||||
e.preventDefault();
|
||||
var err = document.getElementById('loginError');
|
||||
if (err) { err.style.display = 'none'; err.textContent = ''; }
|
||||
|
||||
var email = document.getElementById('email').value.trim();
|
||||
var password = document.getElementById('password').value;
|
||||
|
||||
fetch(getV1Base() + '/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email, password: password }),
|
||||
}).then(function (r) { return r.json(); }).then(function (j) {
|
||||
if (!j.ok) {
|
||||
showError(j.message || '登录失败');
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(TOKEN_KEY, j.data.access_token);
|
||||
localStorage.setItem('soon_refresh', j.data.refresh_token);
|
||||
return verifyAdmin(j.data.access_token);
|
||||
}).then(function (me) {
|
||||
if (me === undefined) return;
|
||||
if (!me) {
|
||||
clearTokens();
|
||||
showError('无管理员权限,请使用管理员账号登录');
|
||||
soonToast('无管理员权限', 'warn');
|
||||
return;
|
||||
}
|
||||
location.href = redirectTarget();
|
||||
}).catch(function () {
|
||||
showError('网络错误,请稍后重试');
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof layui !== 'undefined') {
|
||||
layui.use(['layer'], function () {
|
||||
window.layer = layui.layer;
|
||||
layer.config({ skin: 'soon-layer' });
|
||||
boot();
|
||||
});
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,58 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function init() {
|
||||
if (!AdminApi.getToken()) {
|
||||
AdminApi.goLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
var headerMount = document.getElementById('adminHeaderMount');
|
||||
var env = (window.SOON_DEPLOY_CONFIG && window.SOON_DEPLOY_CONFIG.env) || '';
|
||||
if (headerMount && window.AdminShell) {
|
||||
AdminShell.mountHeader(headerMount, { env: env || '' });
|
||||
}
|
||||
|
||||
AdminApi.fetchMe().then(function (me) {
|
||||
if (!me || me.role !== 'admin') {
|
||||
soonToast('需要管理员权限', 'warn');
|
||||
AdminApi.clearToken();
|
||||
setTimeout(AdminApi.goLogin, 600);
|
||||
return;
|
||||
}
|
||||
AdminState.currentAdminId = me.id;
|
||||
AdminState.adminLevel = AdminPermissions.normalizeLevel(me.admin_level);
|
||||
var emailEl = document.getElementById('adminEmail');
|
||||
if (emailEl) {
|
||||
var levelLabel = AdminState.adminLevel === 'ops' ? '运营' : '';
|
||||
emailEl.textContent = (me.email || '') + (levelLabel ? ' · ' + levelLabel : '');
|
||||
}
|
||||
|
||||
document.getElementById('adminLogout').onclick = function () {
|
||||
AdminApi.clearToken();
|
||||
AdminApi.goLogin();
|
||||
};
|
||||
|
||||
AdminPermissions.applyNav(AdminState.adminLevel);
|
||||
AdminRouter.init(AdminViews);
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof layui !== 'undefined') {
|
||||
layui.use(['layer'], function () {
|
||||
window.layer = layui.layer;
|
||||
layer.config({ skin: 'soon-layer' });
|
||||
boot();
|
||||
});
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,144 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Api = function () { return window.AdminApi; };
|
||||
var Ui = function () { return window.AdminUi; };
|
||||
var I = function () { return window.AdminI18n; };
|
||||
var State = function () { return window.AdminState; };
|
||||
var Filter = function () { return window.AdminFilter; };
|
||||
var Router = function () { return window.AdminRouter; };
|
||||
|
||||
var ACTION_OPTS = [
|
||||
{ v: '', l: '全部操作' },
|
||||
{ v: 'users.setStatus', l: '变更用户状态' },
|
||||
{ v: 'users.create', l: '创建用户' },
|
||||
{ v: 'users.update', l: '编辑用户' },
|
||||
{ v: 'users.delete', l: '删除用户' },
|
||||
{ v: 'users.setPassword', l: '重置密码' },
|
||||
{ v: 'users.setSubscription', l: '设置会员' },
|
||||
{ v: 'users.detail', l: '查看用户详情' },
|
||||
{ v: 'orders.detail', l: '查看订单详情' },
|
||||
{ v: 'orders.cancel', l: '取消订单' },
|
||||
{ v: 'orders.refundApprove', l: '通过退款' },
|
||||
{ v: 'orders.refundReject', l: '拒绝退款' },
|
||||
{ v: 'plans.upsert', l: '编辑套餐' },
|
||||
{ v: 'settings.set', l: '修改系统设置' },
|
||||
{ v: 'settings.set_batch', l: '批量修改设置' },
|
||||
{ v: 'payment.key.upload', l: '上传支付密钥' },
|
||||
];
|
||||
|
||||
function buildQuery() {
|
||||
var st = State().audits;
|
||||
var q = 'audits?page=' + st.page + '&size=' + st.size;
|
||||
if (st.q) q += '&q=' + encodeURIComponent(st.q);
|
||||
if (st.from) q += '&from=' + encodeURIComponent(st.from);
|
||||
if (st.to) q += '&to=' + encodeURIComponent(st.to);
|
||||
if (st.action) q += '&action=' + encodeURIComponent(st.action);
|
||||
return q;
|
||||
}
|
||||
|
||||
function actionOptions(selected) {
|
||||
return ACTION_OPTS.map(function (o) {
|
||||
return '<option value="' + o.v + '"' + (selected === o.v ? ' selected' : '') + '>' + o.l + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function formatContext(ctx) {
|
||||
if (!ctx) return '';
|
||||
var body;
|
||||
try {
|
||||
body = '<pre class="soon-admin-code">' + I().esc(JSON.stringify(JSON.parse(ctx), null, 2)) + '</pre>';
|
||||
} catch (e) {
|
||||
body = '<div class="soon-audit-item__context">' + I().esc(ctx) + '</div>';
|
||||
}
|
||||
return '<details class="soon-audit-item__details"><summary>查看上下文</summary>' + body + '</details>';
|
||||
}
|
||||
|
||||
function renderFilterBar(st) {
|
||||
return Filter().bar(
|
||||
Filter().field('关键词', Filter().searchInput('auditQ', st.q, '操作、目标或管理员')) +
|
||||
Filter().field('操作类型', Filter().select('auditAction', actionOptions(st.action))) +
|
||||
Ui().filterDateField('auditFrom', '从', st.from) +
|
||||
Ui().filterDateField('auditTo', '至', st.to),
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="auditSearchBtn">查询</button>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="auditResetBtn">重置</button>'
|
||||
);
|
||||
}
|
||||
|
||||
function bindFilters(st) {
|
||||
var searchBtn = Ui().el('auditSearchBtn');
|
||||
var resetBtn = Ui().el('auditResetBtn');
|
||||
var qInput = Ui().el('auditQ');
|
||||
if (searchBtn) {
|
||||
searchBtn.onclick = function () {
|
||||
st.q = qInput ? qInput.value.trim() : '';
|
||||
st.action = Ui().el('auditAction').value;
|
||||
st.from = Ui().el('auditFrom').value;
|
||||
st.to = Ui().el('auditTo').value;
|
||||
if (st.from && st.to && st.from > st.to) {
|
||||
soonToast('开始日期不能晚于结束日期', 'warn');
|
||||
return;
|
||||
}
|
||||
st.page = 1;
|
||||
Router().syncQuery();
|
||||
render();
|
||||
};
|
||||
}
|
||||
if (qInput) {
|
||||
qInput.onkeydown = function (e) {
|
||||
if (e.key === 'Enter' && searchBtn) searchBtn.click();
|
||||
};
|
||||
}
|
||||
if (resetBtn) {
|
||||
resetBtn.onclick = function () {
|
||||
State().audits = { page: 1, size: 50, q: '', from: '', to: '', action: '' };
|
||||
Router().syncQuery();
|
||||
render();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function renderTimeline(items) {
|
||||
if (!items.length) {
|
||||
return Ui().emptyBlock('暂无审计记录', '调整筛选条件或等待管理员操作产生日志', '');
|
||||
}
|
||||
return items.map(function (a) {
|
||||
return '<div class="soon-audit-item"><div class="soon-audit-item__title">' +
|
||||
I().esc(I().tAction(a.action)) + ' · ' + I().esc(a.target) + '</div>' +
|
||||
'<div class="soon-audit-item__meta">' + I().esc(a.admin_email || '') + ' · ' +
|
||||
I().formatDateTime(a.created_at) + '</div>' + formatContext(a.context) + '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function render() {
|
||||
var panel = Ui().el('panel-audits');
|
||||
if (!panel) return;
|
||||
var st = State().audits;
|
||||
var header = Ui().pageHeader('审计日志', '管理员操作记录,支持按操作类型与时间筛选');
|
||||
panel.innerHTML = '<div class="soon-admin-page">' + header + renderFilterBar(st) + Ui().skeletonTable() + '</div>';
|
||||
|
||||
Api().get(buildQuery()).then(function (j) {
|
||||
if (!j) {
|
||||
panel.innerHTML = '<div class="soon-admin-page">' + header + renderFilterBar(st) +
|
||||
Ui().listError('审计日志加载失败', 'auditsListRetry') + '</div>';
|
||||
bindFilters(st);
|
||||
Ui().el('auditsListRetry').onclick = render;
|
||||
return;
|
||||
}
|
||||
var items = j.data.items || [];
|
||||
panel.innerHTML = '<div class="soon-admin-page">' + header + renderFilterBar(st) +
|
||||
'<div class="soon-admin-card"><div class="soon-audit-timeline">' + renderTimeline(items) + '</div></div>' +
|
||||
Ui().pagerHtml(j.data.page, j.data.size, j.data.total) + '</div>';
|
||||
bindFilters(st);
|
||||
Ui().bindPager(panel, j.data.page, j.data.size, j.data.total, function (p, newSize) {
|
||||
st.page = p;
|
||||
if (newSize) st.size = newSize;
|
||||
Router().syncQuery();
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.AdminViews = window.AdminViews || {};
|
||||
window.AdminViews.audits = { render: render };
|
||||
})();
|
||||
@@ -0,0 +1,122 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Api = function () { return window.AdminApi; };
|
||||
var Ui = function () { return window.AdminUi; };
|
||||
var I = function () { return window.AdminI18n; };
|
||||
|
||||
function statCard(label, value, opts) {
|
||||
opts = opts || {};
|
||||
var inner = '<div class="soon-admin-stat__label">' + I().esc(label) +
|
||||
'</div><div class="soon-admin-stat__value">' + I().esc(value) + '</div>';
|
||||
if (opts.hint) inner += '<div class="soon-admin-stat__hint">' + I().esc(opts.hint) + '</div>';
|
||||
if (opts.href) {
|
||||
return '<a class="soon-admin-stat soon-admin-stat--link" href="' + I().esc(opts.href) + '">' + inner + '</a>';
|
||||
}
|
||||
return '<div class="soon-admin-stat">' + inner + '</div>';
|
||||
}
|
||||
|
||||
function alertBar(d) {
|
||||
var parts = [];
|
||||
if (d.orders_pending > 0) parts.push('待支付订单 <strong>' + d.orders_pending + '</strong> 笔');
|
||||
if (d.refunds_pending > 0) parts.push('退款待审核 <strong>' + d.refunds_pending + '</strong> 笔');
|
||||
if (!parts.length) return '';
|
||||
var href = '#/orders';
|
||||
if (d.refunds_pending > 0) href = '#/orders?refund_status=pending';
|
||||
else if (d.orders_pending > 0) href = '#/orders?status=pending';
|
||||
return '<div class="soon-admin-alert-bar"><span>' + parts.join(' · ') + '</span>' +
|
||||
'<a href="' + href + '" class="soon-btn soon-btn--sm">去处理</a></div>';
|
||||
}
|
||||
|
||||
function quickLinks() {
|
||||
var links = [
|
||||
{ href: '#/users', label: '用户管理' },
|
||||
{ href: '#/orders', label: '订单管理' },
|
||||
{ href: '#/plans', label: '套餐配置' },
|
||||
{ href: '#/payment', label: '支付密钥' },
|
||||
{ href: '#/settings', label: '系统设置' },
|
||||
{ href: '#/audits', label: '审计日志' },
|
||||
];
|
||||
var level = (window.AdminState && AdminState.adminLevel) || 'full';
|
||||
var visible = window.AdminPermissions
|
||||
? AdminPermissions.filterQuickLinks(links, level)
|
||||
: links;
|
||||
var items = visible.map(function (l) {
|
||||
return '<a class="soon-admin-quick-link" href="' + l.href + '">' + I().esc(l.label) + '</a>';
|
||||
}).join('');
|
||||
return '<div class="soon-admin-card"><h3 class="soon-admin-card__title">快捷入口</h3>' +
|
||||
'<div class="soon-admin-quick-grid">' + items + '</div></div>';
|
||||
}
|
||||
|
||||
function recentOrdersTable(orders) {
|
||||
var rows = (orders || []).map(function (o) {
|
||||
return '<tr><td>' + I().esc(o.order_no || o.id) + '</td><td>' + I().esc(o.email || '') + '</td><td>' +
|
||||
I().formatCents(o.amount_cents) + '</td><td>' + I().badgeStatus(o.status) + '</td><td>' +
|
||||
I().formatDateTime(o.created_at) + '</td></tr>';
|
||||
}).join('');
|
||||
return Ui().dataTable(['订单号', '用户', '金额', '状态', '时间'], rows, 5);
|
||||
}
|
||||
|
||||
function render() {
|
||||
var panel = Ui().el('panel-dashboard');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('运营概览', '关键指标、待办提醒与最近订单',
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="dashRefresh">刷新</button>') +
|
||||
Ui().skeletonStats(6) +
|
||||
'<div class="soon-admin-dash-grid">' +
|
||||
'<div class="soon-admin-card"><h3 class="soon-admin-card__title">最近订单</h3>' + Ui().skeletonTable() + '</div>' +
|
||||
'<div class="soon-skeleton soon-skeleton--h160"></div></div></div>';
|
||||
|
||||
function fetchStats(showLoading) {
|
||||
var btn = Ui().el('dashRefresh');
|
||||
if (showLoading && btn) Ui().setBtnLoading(btn, true, '刷新中…');
|
||||
Api().get('stats').then(function (j) {
|
||||
if (showLoading && btn) Ui().setBtnLoading(btn, false);
|
||||
if (!j) {
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('运营概览', '关键指标、待办提醒与最近订单',
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="dashRefresh">刷新</button>') +
|
||||
Ui().listError('概览数据加载失败', 'dashRetry') + '</div>';
|
||||
Ui().el('dashRetry').onclick = render;
|
||||
Ui().el('dashRefresh').onclick = function () { fetchStats(true); };
|
||||
return;
|
||||
}
|
||||
paintDashboard(panel, j.data);
|
||||
});
|
||||
}
|
||||
|
||||
Ui().el('dashRefresh').onclick = function () { fetchStats(true); };
|
||||
fetchStats(false);
|
||||
}
|
||||
|
||||
function paintDashboard(panel, d) {
|
||||
var stats =
|
||||
statCard('用户总数', d.users, { href: '#/users', hint: '点击进入用户管理' }) +
|
||||
statCard('订单总数', d.orders, { href: '#/orders', hint: '含全部状态' }) +
|
||||
statCard('今日订单', d.orders_today, { href: '#/orders', hint: '按创建日统计' }) +
|
||||
statCard('待支付', d.orders_pending || 0, { href: '#/orders?status=pending', hint: '需关注超时未付' }) +
|
||||
statCard('活跃订阅', d.active_subscriptions, { href: '#/users', hint: '有效会员数' }) +
|
||||
statCard('今日收入', I().formatCents(d.revenue_today_cents), { href: '#/orders', hint: '已支付订单' }) +
|
||||
statCard('云端文件', d.files_total, { hint: '未删除的设计文件' }) +
|
||||
statCard('退款待审', d.refunds_pending || 0, { href: '#/orders?refund_status=pending', hint: '需在订单页处理' });
|
||||
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('运营概览', '关键指标、待办提醒与最近订单',
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="dashRefresh">刷新</button>' +
|
||||
'<a href="#/orders" class="soon-btn soon-btn--sm">全部订单</a>') +
|
||||
alertBar(d) +
|
||||
'<div class="soon-admin-stat-grid soon-admin-stat-grid--dash">' + stats + '</div>' +
|
||||
'<div class="soon-admin-dash-grid">' +
|
||||
'<div class="soon-admin-card"><div class="soon-admin-card__head-row">' +
|
||||
'<h3 class="soon-admin-card__title">最近订单</h3>' +
|
||||
'<a href="#/orders" class="soon-admin-card__link">查看全部</a></div>' +
|
||||
recentOrdersTable(d.recent_orders) + '</div>' +
|
||||
quickLinks() + '</div></div>';
|
||||
|
||||
Ui().el('dashRefresh').onclick = render;
|
||||
}
|
||||
|
||||
window.AdminViews = window.AdminViews || {};
|
||||
window.AdminViews.dashboard = { render: render };
|
||||
})();
|
||||
@@ -0,0 +1,334 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Api = function () { return window.AdminApi; };
|
||||
var Ui = function () { return window.AdminUi; };
|
||||
var I = function () { return window.AdminI18n; };
|
||||
var State = function () { return window.AdminState; };
|
||||
var Filter = function () { return window.AdminFilter; };
|
||||
var Menu = function () { return window.AdminMenu; };
|
||||
var Router = function () { return window.AdminRouter; };
|
||||
|
||||
function buildQuery() {
|
||||
var st = State().orders;
|
||||
var q = 'orders?page=' + st.page + '&size=' + st.size;
|
||||
if (st.q) q += '&q=' + encodeURIComponent(st.q);
|
||||
if (st.status) q += '&status=' + encodeURIComponent(st.status);
|
||||
if (st.refund_status) q += '&refund_status=' + encodeURIComponent(st.refund_status);
|
||||
if (st.channel) q += '&channel=' + encodeURIComponent(st.channel);
|
||||
if (st.from) q += '&from=' + encodeURIComponent(st.from);
|
||||
if (st.to) q += '&to=' + encodeURIComponent(st.to);
|
||||
return q;
|
||||
}
|
||||
|
||||
function statusOptions(selected) {
|
||||
var opts = [{ v: '', l: '全部状态' }, { v: 'pending', l: '待支付' }, { v: 'paid', l: '已支付' },
|
||||
{ v: 'cancelled', l: '已取消' }, { v: 'refunded', l: '已退款' }];
|
||||
return opts.map(function (o) {
|
||||
return '<option value="' + o.v + '"' + (selected === o.v ? ' selected' : '') + '>' + o.l + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function refundStatusOptions(selected) {
|
||||
var opts = [{ v: '', l: '全部退款' }, { v: 'pending', l: '待审核' }, { v: 'none', l: '无申请' },
|
||||
{ v: 'approved', l: '已通过' }, { v: 'rejected', l: '已拒绝' }];
|
||||
return opts.map(function (o) {
|
||||
return '<option value="' + o.v + '"' + (selected === o.v ? ' selected' : '') + '>' + o.l + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function channelOptions(selected) {
|
||||
var opts = [{ v: '', l: '全部渠道' }, { v: 'alipay', l: '支付宝' }, { v: 'wechat', l: '微信支付' }];
|
||||
return opts.map(function (o) {
|
||||
return '<option value="' + o.v + '"' + (selected === o.v ? ' selected' : '') + '>' + o.l + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderFilterBar(st) {
|
||||
return Filter().bar(
|
||||
Filter().field('关键词', Filter().searchInput('orderQ', st.q, '订单号或邮箱')) +
|
||||
Filter().field('状态', Filter().select('orderStatus', statusOptions(st.status))) +
|
||||
Filter().field('退款', Filter().select('orderRefundStatus', refundStatusOptions(st.refund_status))) +
|
||||
Filter().field('渠道', Filter().select('orderChannel', channelOptions(st.channel))) +
|
||||
Ui().filterDateField('orderFrom', '从', st.from) +
|
||||
Ui().filterDateField('orderTo', '至', st.to),
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="orderSearchBtn">查询</button>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="orderResetBtn">重置</button>'
|
||||
);
|
||||
}
|
||||
|
||||
function orderActionMenu(o) {
|
||||
var items = [{ label: '详情', action: 'detail', dataset: { id: String(o.id) } }];
|
||||
if (o.status === 'pending') {
|
||||
items.push({ label: '取消', action: 'cancel', dataset: { id: String(o.id) } });
|
||||
}
|
||||
if (o.status === 'paid' && o.refund_status === 'pending') {
|
||||
items.push({ label: '通过退款', action: 'refund_approve', dataset: { id: String(o.id) } });
|
||||
items.push({ label: '拒绝退款', action: 'refund_reject', dataset: { id: String(o.id) } });
|
||||
}
|
||||
if (o.status === 'paid' && (o.refund_status === 'none' || !o.refund_status)) {
|
||||
items.push({ label: '退款', action: 'refund_direct', dataset: { id: String(o.id) } });
|
||||
}
|
||||
return Menu().actionMenu('order-' + o.id, items);
|
||||
}
|
||||
|
||||
function bindOrderMenus(root) {
|
||||
Menu().bind(root, function (action, btn) {
|
||||
var id = parseInt(btn.dataset.id, 10);
|
||||
if (action === 'detail') showOrderDetail(id);
|
||||
if (action === 'cancel') {
|
||||
Ui().confirm('确认取消该待支付订单?', function () {
|
||||
Api().postJson('orders/' + id + '/cancel', {}).then(function (r) {
|
||||
if (r) { soonToast('订单已取消', 'success'); Ui().closeDrawer(); render(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
if (action === 'refund_approve' || action === 'refund_direct') {
|
||||
var title = action === 'refund_direct' ? '管理员退款' : '通过退款申请';
|
||||
openNoteModal(title, '可选:填写审核备注', function (note) {
|
||||
return Api().postJson('orders/' + id + '/refund/approve', { note: note }).then(function (r) {
|
||||
if (r) { soonToast('退款已处理', 'success'); Ui().closeDrawer(); render(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
if (action === 'refund_reject') {
|
||||
openNoteModal('拒绝退款', '请填写拒绝原因', function (note) {
|
||||
if (!note) {
|
||||
soonToast('请填写拒绝原因', 'warn');
|
||||
return false;
|
||||
}
|
||||
return Api().postJson('orders/' + id + '/refund/reject', { note: note }).then(function (r) {
|
||||
if (r) { soonToast('已拒绝退款', 'success'); Ui().closeDrawer(); render(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
var st = State().orders;
|
||||
st.q = Ui().el('orderQ').value.trim();
|
||||
st.status = Ui().el('orderStatus').value;
|
||||
st.refund_status = Ui().el('orderRefundStatus').value;
|
||||
st.channel = Ui().el('orderChannel').value;
|
||||
st.from = Ui().el('orderFrom').value;
|
||||
st.to = Ui().el('orderTo').value;
|
||||
if (st.from && st.to && st.from > st.to) {
|
||||
soonToast('开始日期不能晚于结束日期', 'warn');
|
||||
return false;
|
||||
}
|
||||
st.page = 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function bindFilters() {
|
||||
Ui().el('orderSearchBtn').onclick = function () {
|
||||
if (applyFilters()) {
|
||||
Router().syncQuery();
|
||||
render();
|
||||
}
|
||||
};
|
||||
Ui().el('orderResetBtn').onclick = function () {
|
||||
State().orders = {
|
||||
page: 1, size: 20, q: '', status: '', refund_status: '', channel: '', from: '', to: '',
|
||||
};
|
||||
Router().syncQuery();
|
||||
render();
|
||||
};
|
||||
}
|
||||
|
||||
function rowActions(o) {
|
||||
var btns = '<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost order-detail-btn" data-id="' +
|
||||
o.id + '">详情</button>';
|
||||
if (o.status === 'pending') {
|
||||
btns += '<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost order-cancel-btn" data-id="' +
|
||||
o.id + '">取消</button>';
|
||||
}
|
||||
if (o.status === 'paid' && o.refund_status === 'pending') {
|
||||
btns += '<button type="button" class="soon-btn soon-btn--xs order-refund-approve-btn" data-id="' + o.id +
|
||||
'">通过退款</button>';
|
||||
btns += '<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost order-refund-reject-btn" data-id="' +
|
||||
o.id + '">拒绝</button>';
|
||||
}
|
||||
if (o.status === 'paid' && (o.refund_status === 'none' || !o.refund_status)) {
|
||||
btns += '<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost order-refund-direct-btn" data-id="' +
|
||||
o.id + '">退款</button>';
|
||||
}
|
||||
return '<div class="soon-admin-btn-group">' + btns + '</div>';
|
||||
}
|
||||
|
||||
function openNoteModal(title, placeholder, onSave) {
|
||||
var form = '<div class="soon-admin-form">' +
|
||||
'<div class="soon-form-field"><label class="soon-form-label">备注</label>' +
|
||||
'<textarea id="orderNote" class="soon-input soon-input--sm" rows="3" placeholder="' + I().esc(placeholder) +
|
||||
'"></textarea></div>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">确认</button></div>';
|
||||
Ui().openFormModal(title, form, function () {
|
||||
return onSave(Ui().el('orderNote').value.trim());
|
||||
});
|
||||
}
|
||||
|
||||
function showOrderDetail(id, opts) {
|
||||
opts = opts || {};
|
||||
if (!opts.fromRoute) Router().openDetail(id);
|
||||
Api().get('orders/' + id).then(function (j) {
|
||||
if (!j) return;
|
||||
var o = j.data.order || {};
|
||||
var sub = j.data.subscription;
|
||||
|
||||
var basic = Ui().detailRow('订单号', I().esc(o.order_no)) +
|
||||
Ui().detailRow('用户', I().esc(o.email || '')) +
|
||||
Ui().detailRow('套餐', I().esc(o.plan_name || '-')) +
|
||||
Ui().detailRow('渠道', I().esc(I().tChannel(o.channel))) +
|
||||
Ui().detailRow('金额', I().formatCents(o.amount_cents)) +
|
||||
Ui().detailRow('订单状态', I().badgeStatus(o.status)) +
|
||||
Ui().detailRow('退款状态', I().badgeRefundStatus(o.refund_status)) +
|
||||
Ui().detailRow('交易号', I().esc(o.txn_id || '-')) +
|
||||
Ui().detailRow('创建时间', I().esc(I().formatDateTime(o.created_at))) +
|
||||
Ui().detailRow('支付时间', I().esc(I().formatDateTime(o.paid_at))) +
|
||||
Ui().detailRow('取消时间', I().esc(I().formatDateTime(o.cancelled_at))) +
|
||||
Ui().detailRow('退款时间', I().esc(I().formatDateTime(o.refunded_at)));
|
||||
|
||||
var refundHtml = '';
|
||||
if (o.refund_reason) {
|
||||
refundHtml += Ui().detailRow('退款原因', I().esc(o.refund_reason));
|
||||
}
|
||||
if (o.refund_note) {
|
||||
refundHtml += Ui().detailRow('审核备注', I().esc(o.refund_note));
|
||||
}
|
||||
if (!refundHtml) {
|
||||
refundHtml = '<p class="soon-admin-detail-empty">无退款记录</p>';
|
||||
}
|
||||
|
||||
var subHtml;
|
||||
if (sub) {
|
||||
subHtml = Ui().detailRow('订阅状态', I().badgeStatus(sub.status)) +
|
||||
Ui().detailRow('开始', I().esc(I().formatDateTime(sub.started_at))) +
|
||||
Ui().detailRow('到期', I().esc(I().formatDateTime(sub.expires_at)));
|
||||
} else {
|
||||
subHtml = '<p class="soon-admin-detail-empty">无关联订阅</p>';
|
||||
}
|
||||
|
||||
var drawerActions = rowActions(o).replace(/<button[^>]*order-detail-btn[^>]*>[\s\S]*?<\/button>/, '');
|
||||
Ui().openDrawer('订单 ' + I().esc(o.order_no),
|
||||
'<div class="soon-admin-drawer-actions">' + drawerActions + '</div>' +
|
||||
Ui().detailSection('订单信息', basic) +
|
||||
Ui().detailSection('退款信息', refundHtml) +
|
||||
Ui().detailSection('关联会员', subHtml));
|
||||
|
||||
var drawer = document.getElementById('adminDrawer');
|
||||
if (drawer) bindRowActions(drawer);
|
||||
});
|
||||
}
|
||||
|
||||
function bindRowActions(root) {
|
||||
if (!root) return;
|
||||
root.querySelectorAll('.order-detail-btn').forEach(function (btn) {
|
||||
btn.onclick = function () { showOrderDetail(parseInt(btn.dataset.id, 10)); };
|
||||
});
|
||||
root.querySelectorAll('.order-cancel-btn').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
Ui().confirm('确认取消该待支付订单?', function () {
|
||||
Api().postJson('orders/' + btn.dataset.id + '/cancel', {}).then(function (r) {
|
||||
if (r) {
|
||||
soonToast('订单已取消', 'success');
|
||||
Ui().closeDrawer();
|
||||
render();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
root.querySelectorAll('.order-refund-approve-btn, .order-refund-direct-btn').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
var isDirect = btn.classList.contains('order-refund-direct-btn');
|
||||
var title = isDirect ? '管理员退款' : '通过退款申请';
|
||||
openNoteModal(title, '可选:填写审核备注', function (note) {
|
||||
return Api().postJson('orders/' + btn.dataset.id + '/refund/approve', { note: note }).then(function (r) {
|
||||
if (r) {
|
||||
layer.closeAll();
|
||||
var gw = r.data && r.data.gateway;
|
||||
var msg = '退款已完成';
|
||||
if (gw && gw.indexOf('skipped') === 0) msg += '(未配置支付密钥,仅标记退款并撤销会员)';
|
||||
else if (gw && gw.indexOf('fail') >= 0) msg += '(支付渠道退款失败,已标记退款并撤销会员)';
|
||||
soonToast(msg, 'success');
|
||||
Ui().closeDrawer();
|
||||
render();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
root.querySelectorAll('.order-refund-reject-btn').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
openNoteModal('拒绝退款', '请填写拒绝原因', function (note) {
|
||||
if (!note) {
|
||||
soonToast('请填写拒绝原因', 'warn');
|
||||
return false;
|
||||
}
|
||||
return Api().postJson('orders/' + btn.dataset.id + '/refund/reject', { note: note }).then(function (r) {
|
||||
if (r) {
|
||||
layer.closeAll();
|
||||
soonToast('已拒绝退款申请', 'success');
|
||||
Ui().closeDrawer();
|
||||
render();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
var panel = Ui().el('panel-orders');
|
||||
if (!panel) return;
|
||||
if (!Router().hasPendingDetail()) Ui().closeDrawer();
|
||||
var st = State().orders;
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('订单管理', '支付订单查询、取消与退款审核') +
|
||||
renderFilterBar(st) + Ui().skeletonTable() + '</div>';
|
||||
|
||||
Api().get(buildQuery()).then(function (j) {
|
||||
if (!j) {
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('订单管理', '支付订单查询、取消与退款审核') +
|
||||
renderFilterBar(st) + Ui().listError('订单列表加载失败', 'ordersListRetry') + '</div>';
|
||||
bindFilters();
|
||||
Ui().el('ordersListRetry').onclick = render;
|
||||
var errDetailId = Router().consumeDetailId();
|
||||
if (errDetailId) showOrderDetail(errDetailId, { fromRoute: true });
|
||||
return;
|
||||
}
|
||||
var rows = (j.data.items || []).map(function (o) {
|
||||
return '<tr><td>' + I().esc(o.order_no || o.id) + '</td><td>' + I().esc(o.email || '') + '</td><td>' +
|
||||
I().esc(o.plan_name || '-') + '</td><td>' + I().esc(I().tChannel(o.channel)) + '</td><td>' +
|
||||
I().formatCents(o.amount_cents) + '</td><td>' + I().badgeStatus(o.status) + '</td><td>' +
|
||||
I().badgeRefundStatus(o.refund_status) + '</td><td>' + I().formatDateTime(o.created_at) + '</td><td>' +
|
||||
I().formatDateTime(o.paid_at) + '</td><td>' + orderActionMenu(o) + '</td></tr>';
|
||||
}).join('');
|
||||
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('订单管理', '支付订单查询、取消与退款审核') +
|
||||
renderFilterBar(st) +
|
||||
Ui().dataTable(
|
||||
['订单号', '用户', '套餐', '渠道', '金额', '状态', '退款', '创建时间', '支付时间', '操作'],
|
||||
rows
|
||||
) +
|
||||
Ui().pagerHtml(j.data.page, j.data.size, j.data.total) + '</div>';
|
||||
|
||||
bindFilters();
|
||||
Ui().bindPager(panel, j.data.page, j.data.size, j.data.total, function (p, newSize) {
|
||||
st.page = p;
|
||||
if (newSize) st.size = newSize;
|
||||
Router().syncQuery();
|
||||
render();
|
||||
});
|
||||
bindOrderMenus(panel);
|
||||
var detailId = Router().consumeDetailId();
|
||||
if (detailId) showOrderDetail(detailId, { fromRoute: true });
|
||||
});
|
||||
}
|
||||
|
||||
window.AdminViews = window.AdminViews || {};
|
||||
window.AdminViews.orders = { render: render };
|
||||
})();
|
||||
@@ -0,0 +1,290 @@
|
||||
(function () {
|
||||
|
||||
'use strict';
|
||||
|
||||
|
||||
|
||||
var Api = function () { return window.AdminApi; };
|
||||
|
||||
var Ui = function () { return window.AdminUi; };
|
||||
|
||||
var I = function () { return window.AdminI18n; };
|
||||
|
||||
var Form = function () { return window.AdminForm; };
|
||||
|
||||
var Tabs = function () { return window.AdminTabs; };
|
||||
|
||||
|
||||
|
||||
var CHANNEL_CONFIG = {
|
||||
|
||||
alipay: {
|
||||
|
||||
label: '支付宝',
|
||||
|
||||
kinds: [
|
||||
|
||||
{ kind: 'private_key', file: 'alipay_private_key.pem', label: '应用私钥 private_key' },
|
||||
|
||||
{ kind: 'public_key', file: 'alipay_public_key.pem', label: '支付宝公钥 public_key' },
|
||||
|
||||
],
|
||||
|
||||
},
|
||||
|
||||
wechat: {
|
||||
|
||||
label: '微信支付',
|
||||
|
||||
kinds: [
|
||||
|
||||
{ kind: 'mch_private_key', file: 'wechat_mch_private_key.pem', label: '商户私钥 mch_private_key' },
|
||||
|
||||
{ kind: 'api_v3_key', file: 'wechat_api_v3_key.pem', label: 'APIv3 密钥 api_v3_key' },
|
||||
|
||||
{ kind: 'platform_cert', file: 'wechat_platform_cert.pem', label: '平台证书 platform_cert' },
|
||||
|
||||
],
|
||||
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
function fileSet(items) {
|
||||
|
||||
var set = {};
|
||||
|
||||
(items || []).forEach(function (p) { set[p.name] = p; });
|
||||
|
||||
return set;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function channelFormHtml(channel, files) {
|
||||
|
||||
var cfg = CHANNEL_CONFIG[channel];
|
||||
|
||||
var rows = cfg.kinds.map(function (k) {
|
||||
|
||||
var ok = !!files[k.file];
|
||||
|
||||
var statusCls = ok ? 'soon-admin-key-form-row__status--ok' : 'soon-admin-key-form-row__status--miss';
|
||||
|
||||
var statusText = ok ? '已就绪' : '缺失';
|
||||
|
||||
var id = channel + '_' + k.kind;
|
||||
|
||||
return '<div class="soon-admin-key-form-row">' +
|
||||
|
||||
'<div><div class="soon-admin-key-form-row__label">' + I().esc(k.label) + '</div>' +
|
||||
|
||||
'<div class="soon-admin-key-form-row__status ' + statusCls + '">' + statusText + '</div></div>' +
|
||||
|
||||
'<textarea id="' + id + '" class="soon-input soon-input--sm soon-input--textarea" rows="4" ' +
|
||||
|
||||
'placeholder="粘贴 PEM 内容或拖入 .pem 文件"></textarea>' +
|
||||
|
||||
'<button type="button" class="soon-btn soon-btn--sm pay-upload-btn" data-channel="' + channel +
|
||||
|
||||
'" data-kind="' + k.kind + '" data-target="' + id + '">上传</button></div>';
|
||||
|
||||
}).join('');
|
||||
|
||||
return '<div class="soon-admin-form">' + Form().panel(cfg.label + ' 密钥', rows) +
|
||||
|
||||
'<details class="soon-admin-hint"><summary>回调说明</summary>' +
|
||||
|
||||
'<p>notify:<code>/api/v1/pay/' + channel + '/notify</code>(须配置 site.base_url 为 HTTPS 外网地址)</p></details>' +
|
||||
|
||||
'<div class="soon-admin-upload" id="payDrop_' + channel + '">拖拽 .pem 到此处自动填入对应字段</div></div>';
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function filesTabHtml(items) {
|
||||
|
||||
var rows = (items || []).map(function (p) {
|
||||
|
||||
return '<tr><td>' + I().esc(p.name) + '</td><td>' + p.bytes + ' B</td><td>' +
|
||||
|
||||
I().formatDateTime(p.mtime) + '</td></tr>';
|
||||
|
||||
}).join('');
|
||||
|
||||
return Ui().dataTable(['文件名', '大小', '修改时间'], rows);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function uploadPem(channel, kind, content, onDone) {
|
||||
|
||||
return Api().fetch('payment/upload', {
|
||||
|
||||
method: 'POST',
|
||||
|
||||
body: JSON.stringify({ channel: channel, kind: kind, content: content }),
|
||||
|
||||
}).then(function (j) {
|
||||
|
||||
if (j) {
|
||||
|
||||
soonToast('密钥已上传', 'success');
|
||||
|
||||
if (onDone) onDone();
|
||||
|
||||
}
|
||||
|
||||
return j;
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function bindChannel(channel, panel) {
|
||||
|
||||
panel.querySelectorAll('.pay-upload-btn').forEach(function (btn) {
|
||||
|
||||
btn.onclick = function () {
|
||||
|
||||
var target = Ui().el(btn.dataset.target);
|
||||
|
||||
var content = target ? target.value.trim() : '';
|
||||
|
||||
if (!content) {
|
||||
|
||||
soonToast('请先粘贴或拖入 PEM 内容', 'warn');
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
Ui().runFormSubmit(btn, function () {
|
||||
return uploadPem(btn.dataset.channel, btn.dataset.kind, content, render);
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
var drop = Ui().el('payDrop_' + channel);
|
||||
|
||||
if (!drop) return;
|
||||
|
||||
drop.ondragover = function (e) { e.preventDefault(); drop.classList.add('is-dragover'); };
|
||||
|
||||
drop.ondragleave = function () { drop.classList.remove('is-dragover'); };
|
||||
|
||||
drop.ondrop = function (e) {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
drop.classList.remove('is-dragover');
|
||||
|
||||
var file = e.dataTransfer.files[0];
|
||||
|
||||
if (!file) return;
|
||||
|
||||
file.text().then(function (text) {
|
||||
|
||||
var name = file.name.toLowerCase();
|
||||
|
||||
var cfg = CHANNEL_CONFIG[channel];
|
||||
|
||||
var matched = cfg.kinds.find(function (k) { return name.indexOf(k.kind.replace(/_/g, '')) >= 0 || name === k.file; });
|
||||
|
||||
var targetId = matched ? channel + '_' + matched.kind : channel + '_' + cfg.kinds[0].kind;
|
||||
|
||||
var ta = Ui().el(targetId);
|
||||
|
||||
if (ta) ta.value = text;
|
||||
|
||||
soonToast('已填入 ' + (matched ? matched.label : '首个字段') + ',请确认后点击上传', 'success');
|
||||
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function render() {
|
||||
|
||||
var panel = Ui().el('panel-payment');
|
||||
|
||||
if (!panel) return;
|
||||
|
||||
panel.innerHTML = '<div class="soon-admin-page">' + Ui().pageHeader('支付配置', '按渠道管理密钥文件,上传后立即热加载') +
|
||||
|
||||
Ui().skeletonTable() + '</div>';
|
||||
|
||||
|
||||
|
||||
Api().get('payment/status').then(function (j) {
|
||||
|
||||
if (!j) return;
|
||||
|
||||
var items = j.data.items || [];
|
||||
|
||||
var files = fileSet(items);
|
||||
|
||||
|
||||
|
||||
var tabsHtml = Tabs().html('paymentTabs', [
|
||||
|
||||
{ id: 'alipay', label: '支付宝' },
|
||||
|
||||
{ id: 'wechat', label: '微信支付' },
|
||||
|
||||
{ id: 'files', label: '文件清单' },
|
||||
|
||||
], {
|
||||
|
||||
alipay: channelFormHtml('alipay', files),
|
||||
|
||||
wechat: channelFormHtml('wechat', files),
|
||||
|
||||
files: filesTabHtml(items),
|
||||
|
||||
}, 'alipay');
|
||||
|
||||
|
||||
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
|
||||
Ui().pageHeader('支付配置', '按渠道管理密钥文件,上传后立即热加载') +
|
||||
|
||||
tabsHtml + '</div>';
|
||||
|
||||
|
||||
|
||||
var tabsRoot = Ui().el('paymentTabs');
|
||||
|
||||
Tabs().bind(tabsRoot);
|
||||
|
||||
bindChannel('alipay', panel);
|
||||
|
||||
bindChannel('wechat', panel);
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
window.AdminViews = window.AdminViews || {};
|
||||
|
||||
window.AdminViews.payment = { render: render };
|
||||
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Api = function () { return window.AdminApi; };
|
||||
var Ui = function () { return window.AdminUi; };
|
||||
var I = function () { return window.AdminI18n; };
|
||||
var Form = function () { return window.AdminForm; };
|
||||
|
||||
function activeBadge(v) {
|
||||
var on = v === true || parseInt(v, 10) === 1;
|
||||
return I().badgeStatus(on ? 'active' : 'disabled');
|
||||
}
|
||||
|
||||
function recBadge(v) {
|
||||
if (!v) return '—';
|
||||
return '<span class="soon-badge soon-badge--paid">推荐</span>';
|
||||
}
|
||||
|
||||
function isFreePlan(plan) {
|
||||
return plan && plan.code === 'free';
|
||||
}
|
||||
|
||||
function isPaidMemberPlan(plan) {
|
||||
return plan && plan.code !== 'free' && plan.price_cents > 0;
|
||||
}
|
||||
|
||||
function editableFeatures(plan) {
|
||||
if (plan.features_editable && plan.features_editable.length) {
|
||||
return plan.features_editable.slice();
|
||||
}
|
||||
var lines = plan.features || [];
|
||||
return lines.filter(function (l) {
|
||||
return l.indexOf('订阅有效期') < 0
|
||||
&& l.indexOf('订阅周期') < 0
|
||||
&& l.indexOf('到期') < 0;
|
||||
});
|
||||
}
|
||||
|
||||
function featureRowsHtml(lines) {
|
||||
var items = lines && lines.length ? lines : [''];
|
||||
return items.map(function (text) {
|
||||
return '<div class="soon-admin-feature-row">' +
|
||||
'<input type="text" class="soon-input soon-input--sm soon-admin-feature-input" value="' +
|
||||
I().esc(text) + '" placeholder="例如:预览成品效果">' +
|
||||
'<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-admin-feature-del" title="删除">×</button></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function bindFeatureEditor() {
|
||||
var list = document.getElementById('peFeatures');
|
||||
var addBtn = document.getElementById('peFeatureAdd');
|
||||
if (!list) return;
|
||||
|
||||
function bindDel(btn) {
|
||||
if (!btn || btn.dataset.bound) return;
|
||||
btn.dataset.bound = '1';
|
||||
btn.onclick = function () {
|
||||
var rows = list.querySelectorAll('.soon-admin-feature-row');
|
||||
if (rows.length <= 1) {
|
||||
var inp = list.querySelector('.soon-admin-feature-input');
|
||||
if (inp) inp.value = '';
|
||||
return;
|
||||
}
|
||||
var row = btn.closest('.soon-admin-feature-row');
|
||||
if (row) row.remove();
|
||||
};
|
||||
}
|
||||
|
||||
list.querySelectorAll('.soon-admin-feature-del').forEach(bindDel);
|
||||
|
||||
if (addBtn && !addBtn.dataset.bound) {
|
||||
addBtn.dataset.bound = '1';
|
||||
addBtn.onclick = function () {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'soon-admin-feature-row';
|
||||
row.innerHTML =
|
||||
'<input type="text" class="soon-input soon-input--sm soon-admin-feature-input" placeholder="例如:预览成品效果">' +
|
||||
'<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost soon-admin-feature-del" title="删除">×</button>';
|
||||
list.appendChild(row);
|
||||
bindDel(row.querySelector('.soon-admin-feature-del'));
|
||||
row.querySelector('.soon-admin-feature-input').focus();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function collectFeatures() {
|
||||
var lines = [];
|
||||
document.querySelectorAll('#peFeatures .soon-admin-feature-input').forEach(function (inp) {
|
||||
var t = inp.value.trim();
|
||||
if (t) lines.push(t);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
function periodHint(plan) {
|
||||
if (isFreePlan(plan)) return '';
|
||||
var days = parseInt(Ui().el('peDays') && Ui().el('peDays').value, 10);
|
||||
if (!days || days <= 0) days = plan.duration_days || 0;
|
||||
if (days <= 0) return '付费方案保存后将自动追加订阅周期说明。';
|
||||
var label = days >= 365 ? '1 年' : (days === 90 ? '1 季' : (days >= 30 && days % 30 === 0 ? (days / 30) + ' 个月' : days + ' 天'));
|
||||
return '保存后将自动追加订阅周期说明(' + label + '),用户端卡片中不展示该行';
|
||||
}
|
||||
|
||||
function openEditModal(plan) {
|
||||
var free = isFreePlan(plan);
|
||||
var paid = isPaidMemberPlan(plan);
|
||||
var features = editableFeatures(plan);
|
||||
|
||||
var basicPanel = Form().panel('基本信息',
|
||||
Form().field('代码', '<input class="soon-input soon-input--sm" value="' + I().esc(plan.code) + '" disabled>') +
|
||||
Form().field('名称', Form().input('peName', { value: plan.name })) +
|
||||
Form().field('简介', Form().input('peDesc', { value: plan.description || '' }))
|
||||
);
|
||||
|
||||
var pricePanel = Form().panel('价格与周期',
|
||||
'<div class="soon-admin-form-grid">' +
|
||||
Form().field('价格(元)',
|
||||
Form().input('pePriceYuan', {
|
||||
type: 'number',
|
||||
value: (plan.price_cents / 100).toFixed(2),
|
||||
disabled: free,
|
||||
})) +
|
||||
Form().field('有效天数',
|
||||
Form().input('peDays', { type: 'number', value: plan.duration_days }),
|
||||
free ? '免费版填 0' : '月付 30 / 季付 90 / 年付 365') +
|
||||
Form().field('排序',
|
||||
Form().input('peSort', { type: 'number', value: plan.sort_order })) +
|
||||
'</div>' +
|
||||
'<div class="soon-admin-form-checks">' +
|
||||
'<label class="soon-admin-check"><input type="checkbox" id="peRec"' +
|
||||
(plan.is_recommended ? ' checked' : '') + (free ? ' disabled' : '') + '> 设为推荐</label>' +
|
||||
'<label class="soon-admin-check"><input type="checkbox" id="peActive"' +
|
||||
(plan.is_active ? ' checked' : '') + '> 上架展示</label>' +
|
||||
(paid ? '<label class="soon-admin-check"><input type="checkbox" id="peSync" checked> 应用到全部付费方案</label>' : '') +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
var quotaPanel = Form().panel('云端配额',
|
||||
'<div class="soon-admin-form-grid">' +
|
||||
Form().field('存储(MB)', Form().input('peQuota', { type: 'number', value: plan.quota_mb })) +
|
||||
Form().field('文件数上限', Form().input('peMaxFiles', { type: 'number', value: plan.max_files })) +
|
||||
'</div>',
|
||||
'云端存储与文件数量上限;预览/保存/导出仍受订阅状态约束。'
|
||||
);
|
||||
|
||||
var benefitTitle = free ? '免费版展示' : '订阅包含';
|
||||
var benefitHint = free
|
||||
? '展示在会员中心免费版区域;补充说明写在简介中。'
|
||||
: periodHint(plan);
|
||||
var benefitPanel = Form().panel(benefitTitle,
|
||||
'<div class="soon-admin-feature-list" id="peFeatures">' + featureRowsHtml(features) + '</div>' +
|
||||
'<button type="button" class="soon-btn soon-btn--xs soon-btn--ghost" id="peFeatureAdd">+ 添加权益</button>' +
|
||||
(benefitHint ? '<p class="soon-admin-form-hint soon-admin-form-hint--tight">' + I().esc(benefitHint) + '</p>' : '')
|
||||
);
|
||||
|
||||
var form = '<div class="soon-admin-form soon-admin-form--plan">' +
|
||||
basicPanel + pricePanel + quotaPanel + benefitPanel +
|
||||
Form().actions('保存', 'adminModalSave') +
|
||||
'</div>';
|
||||
|
||||
Ui().openFormModal((free ? '免费版' : '订阅方案') + ' · ' + plan.name, form, function () {
|
||||
var name = Ui().el('peName').value.trim();
|
||||
if (!name) {
|
||||
soonToast('名称不能为空', 'warn');
|
||||
return false;
|
||||
}
|
||||
var featureLines = collectFeatures();
|
||||
if (!featureLines.length) {
|
||||
soonToast('请至少填写一条权益', 'warn');
|
||||
return false;
|
||||
}
|
||||
var yuan = free ? 0 : (parseFloat(Ui().el('pePriceYuan').value) || 0);
|
||||
var body = {
|
||||
code: plan.code,
|
||||
name: name,
|
||||
description: Ui().el('peDesc').value.trim(),
|
||||
price_cents: Math.round(yuan * 100),
|
||||
quota_mb: parseInt(Ui().el('peQuota').value, 10) || 0,
|
||||
max_files: parseInt(Ui().el('peMaxFiles').value, 10) || 0,
|
||||
duration_days: parseInt(Ui().el('peDays').value, 10) || 0,
|
||||
sort_order: parseInt(Ui().el('peSort').value, 10) || 0,
|
||||
is_recommended: free ? 0 : (Ui().el('peRec').checked ? 1 : 0),
|
||||
is_active: Ui().el('peActive').checked ? 1 : 0,
|
||||
features: featureLines,
|
||||
sync_member_benefits: paid && Ui().el('peSync') && Ui().el('peSync').checked ? 1 : 0,
|
||||
};
|
||||
return Api().postJson('plans', body).then(function (r) {
|
||||
if (r) {
|
||||
layer.closeAll();
|
||||
soonToast('已保存', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
}, {
|
||||
area: ['640px', '72vh'],
|
||||
success: function () {
|
||||
bindFeatureEditor();
|
||||
var daysEl = Ui().el('peDays');
|
||||
var hintEl = document.querySelector('.soon-admin-form-hint--tight');
|
||||
if (daysEl && hintEl && paid) {
|
||||
daysEl.oninput = function () {
|
||||
hintEl.textContent = periodHint(plan);
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function featureSummary(plan) {
|
||||
var n = editableFeatures(plan).length;
|
||||
return n > 0 ? n + ' 条' : '—';
|
||||
}
|
||||
|
||||
function render() {
|
||||
var panel = Ui().el('panel-plans');
|
||||
if (!panel) return;
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('订阅方案', '配置价格、周期、配额与用户端展示文案') +
|
||||
Ui().skeletonTable() + '</div>';
|
||||
|
||||
Api().get('plans').then(function (j) {
|
||||
if (!j) return;
|
||||
var items = j.data.items || [];
|
||||
var rows = items.map(function (p) {
|
||||
return '<tr data-code="' + I().esc(p.code) + '"><td>' + I().esc(p.code) + '</td><td>' + I().esc(p.name) +
|
||||
'</td><td>' + I().formatCents(p.price_cents) + '</td><td>' + featureSummary(p) + '</td><td>' +
|
||||
p.quota_mb + ' MB</td><td>' + (p.duration_days > 0 ? p.duration_days + ' 天' : '永久') + '</td><td>' +
|
||||
recBadge(p.is_recommended) + '</td><td>' + activeBadge(p.is_active) +
|
||||
'</td><td><button type="button" class="soon-btn soon-btn--sm soon-btn--ghost plan-edit">编辑</button></td></tr>';
|
||||
}).join('');
|
||||
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('订阅方案', '配置价格、周期、配额与用户端展示文案') +
|
||||
Ui().dataTable(
|
||||
['代码', '名称', '价格', '权益', '存储', '有效期', '推荐', '状态', '操作'],
|
||||
rows
|
||||
) + '</div>';
|
||||
|
||||
var planMap = {};
|
||||
items.forEach(function (p) { planMap[p.code] = p; });
|
||||
|
||||
panel.querySelectorAll('.plan-edit').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
var code = btn.closest('tr').dataset.code;
|
||||
if (planMap[code]) openEditModal(planMap[code]);
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.AdminViews = window.AdminViews || {};
|
||||
window.AdminViews.plans = { render: render };
|
||||
})();
|
||||
@@ -0,0 +1,191 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Api = function () { return window.AdminApi; };
|
||||
var Ui = function () { return window.AdminUi; };
|
||||
var I = function () { return window.AdminI18n; };
|
||||
var Form = function () { return window.AdminForm; };
|
||||
var Tabs = function () { return window.AdminTabs; };
|
||||
var Filter = function () { return window.AdminFilter; };
|
||||
var State = function () { return window.AdminState; };
|
||||
var Router = function () { return window.AdminRouter; };
|
||||
|
||||
var SETTING_LABELS = {
|
||||
'site.base_url': 'API 站点外网地址',
|
||||
'site.front_base_url': '前端站点地址',
|
||||
};
|
||||
|
||||
var valuesByKey = {};
|
||||
var allItems = [];
|
||||
|
||||
function siteFields() {
|
||||
var cfg = window.SOON_DEPLOY_CONFIG || {};
|
||||
var apiBase = String(cfg.api_v1_base || '').replace(/\/api\/v1\/?$/, '');
|
||||
var frontBase = cfg.front_base_url || cfg.web_base || '';
|
||||
return [
|
||||
{
|
||||
key: 'site.base_url',
|
||||
label: 'API 站点外网地址',
|
||||
hint: '须 HTTPS 外网可达;支付宝/微信 notify 回调依赖此地址',
|
||||
placeholder: apiBase,
|
||||
},
|
||||
{
|
||||
key: 'site.front_base_url',
|
||||
label: '前端站点地址',
|
||||
hint: '支付完成 return_url 跳转至会员页',
|
||||
placeholder: frontBase,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function settingLabel(key) {
|
||||
if (SETTING_LABELS[key]) {
|
||||
return I().esc(key) + '<br><span class="soon-admin-setting-hint">' + I().esc(SETTING_LABELS[key]) + '</span>';
|
||||
}
|
||||
return I().esc(key);
|
||||
}
|
||||
|
||||
function maskValue(key, val) {
|
||||
if (/secret|password|key/i.test(key) && val) return '******';
|
||||
return I().esc(val);
|
||||
}
|
||||
|
||||
function siteFormHtml() {
|
||||
var fields = siteFields().map(function (f) {
|
||||
var val = valuesByKey[f.key] != null ? valuesByKey[f.key] : '';
|
||||
return Form().field(f.label,
|
||||
Form().input('cfg_' + f.key.replace(/\./g, '_'), { value: val, placeholder: f.placeholder }),
|
||||
f.hint);
|
||||
}).join('');
|
||||
return '<div class="soon-admin-form">' + Form().panel('站点与回调地址', fields) +
|
||||
Form().actions('保存站点配置', 'settingsSiteSave') + '</div>';
|
||||
}
|
||||
|
||||
function allSettingsHtml(filterQ) {
|
||||
var q = (filterQ || '').toLowerCase();
|
||||
var items = allItems.filter(function (s) {
|
||||
if (!q) return true;
|
||||
return String(s.key).toLowerCase().indexOf(q) >= 0 ||
|
||||
String(s.value || '').toLowerCase().indexOf(q) >= 0;
|
||||
});
|
||||
var rows = items.map(function (s) {
|
||||
return '<tr><td>' + settingLabel(s.key) + '</td><td>' + maskValue(s.key, s.value) + '</td><td>' +
|
||||
I().formatDateTime(s.updated_at) + '</td><td>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost setting-edit" data-key="' +
|
||||
I().esc(s.key) + '">编辑</button></td></tr>';
|
||||
}).join('');
|
||||
var filter = Filter().bar(
|
||||
Filter().field('搜索', Filter().searchInput('settingsSearchQ', filterQ, '按 key 或 value 搜索')),
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="settingsSearchBtn">搜索</button>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="settingsSearchReset">重置</button>'
|
||||
);
|
||||
return filter + Ui().dataTable(['配置项', '当前值', '更新时间', '操作'], rows);
|
||||
}
|
||||
|
||||
function bindSiteForm() {
|
||||
Ui().bindFormSubmit('settingsSiteSave', function () {
|
||||
var items = siteFields().map(function (f) {
|
||||
var el = Ui().el('cfg_' + f.key.replace(/\./g, '_'));
|
||||
return { key: f.key, value: el ? el.value.trim() : '' };
|
||||
});
|
||||
return Api().postJson('settings/batch', { items: items }).then(function (r) {
|
||||
if (r) {
|
||||
soonToast('站点配置已保存', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindAllSettings() {
|
||||
var st = State().settings;
|
||||
var searchBtn = Ui().el('settingsSearchBtn');
|
||||
var resetBtn = Ui().el('settingsSearchReset');
|
||||
if (searchBtn) {
|
||||
searchBtn.onclick = function () {
|
||||
st.q = Ui().el('settingsSearchQ').value.trim();
|
||||
st.tab = 'all';
|
||||
Router().syncQuery();
|
||||
render();
|
||||
};
|
||||
}
|
||||
if (resetBtn) {
|
||||
resetBtn.onclick = function () {
|
||||
st.q = '';
|
||||
st.tab = 'all';
|
||||
Router().syncQuery();
|
||||
render();
|
||||
};
|
||||
}
|
||||
var searchInput = Ui().el('settingsSearchQ');
|
||||
if (searchInput) {
|
||||
searchInput.onkeydown = function (e) {
|
||||
if (e.key === 'Enter' && searchBtn) searchBtn.click();
|
||||
};
|
||||
}
|
||||
document.querySelectorAll('#panel-settings .setting-edit').forEach(function (btn) {
|
||||
btn.onclick = function () {
|
||||
var key = btn.dataset.key;
|
||||
var val = valuesByKey[key] != null ? valuesByKey[key] : '';
|
||||
var form = '<div class="soon-admin-form">' +
|
||||
Form().field('配置键', '<input class="soon-input soon-input--sm" value="' + I().esc(key) + '" disabled>') +
|
||||
Form().field('配置值', Form().input('settingVal', { value: val, type: 'textarea', rows: 5 })) +
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">保存</button></div>';
|
||||
Ui().openFormModal('修改 ' + key, form, function () {
|
||||
Ui().confirm('确认保存该配置?', function () {
|
||||
var saveBtn = Ui().el('adminModalSave');
|
||||
Ui().setBtnLoading(saveBtn, true);
|
||||
Api().postJson('settings', { key: key, value: Ui().el('settingVal').value }).then(function (r) {
|
||||
if (r) {
|
||||
layer.closeAll();
|
||||
soonToast('配置已保存', 'success');
|
||||
render();
|
||||
}
|
||||
}).finally(function () {
|
||||
Ui().setBtnLoading(saveBtn, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
var panel = Ui().el('panel-settings');
|
||||
if (!panel) return;
|
||||
var st = State().settings;
|
||||
var header = Ui().pageHeader('系统设置', '站点地址与运行时参数;修改后即时生效');
|
||||
panel.innerHTML = '<div class="soon-admin-page">' + header + Ui().skeletonTable() + '</div>';
|
||||
|
||||
Api().get('settings').then(function (j) {
|
||||
if (!j) {
|
||||
panel.innerHTML = '<div class="soon-admin-page">' + header +
|
||||
Ui().listError('设置加载失败', 'settingsRetry') + '</div>';
|
||||
Ui().el('settingsRetry').onclick = render;
|
||||
return;
|
||||
}
|
||||
valuesByKey = {};
|
||||
allItems = j.data.items || [];
|
||||
allItems.forEach(function (s) { valuesByKey[s.key] = s.value; });
|
||||
|
||||
var tabsHtml = Tabs().html('settingsTabs', [
|
||||
{ id: 'site', label: '站点配置' },
|
||||
{ id: 'all', label: '全部配置' },
|
||||
], {
|
||||
site: siteFormHtml(),
|
||||
all: allSettingsHtml(st.q),
|
||||
}, st.tab);
|
||||
|
||||
panel.innerHTML = '<div class="soon-admin-page">' + header + tabsHtml + '</div>';
|
||||
Tabs().bind(Ui().el('settingsTabs'), function (tabId) {
|
||||
st.tab = tabId;
|
||||
Router().syncQuery();
|
||||
});
|
||||
bindSiteForm();
|
||||
bindAllSettings();
|
||||
});
|
||||
}
|
||||
|
||||
window.AdminViews = window.AdminViews || {};
|
||||
window.AdminViews.settings = { render: render };
|
||||
})();
|
||||
@@ -0,0 +1,417 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Api = function () { return window.AdminApi; };
|
||||
var Ui = function () { return window.AdminUi; };
|
||||
var I = function () { return window.AdminI18n; };
|
||||
var State = function () { return window.AdminState; };
|
||||
var Filter = function () { return window.AdminFilter; };
|
||||
var Menu = function () { return window.AdminMenu; };
|
||||
var Router = function () { return window.AdminRouter; };
|
||||
|
||||
function buildQuery() {
|
||||
var st = State().users;
|
||||
var q = 'users?page=' + st.page + '&size=' + st.size;
|
||||
if (st.q) q += '&q=' + encodeURIComponent(st.q);
|
||||
return q;
|
||||
}
|
||||
|
||||
function formField(label, inner) {
|
||||
return '<div class="soon-form-field"><label class="soon-form-label">' + label + '</label>' + inner + '</div>';
|
||||
}
|
||||
|
||||
function roleOptions(selected) {
|
||||
var roles = ['user'];
|
||||
if (State().adminLevel === 'full') roles.push('admin');
|
||||
return roles.map(function (r) {
|
||||
return '<option value="' + r + '"' + (selected === r ? ' selected' : '') + '>' + I().tRole(r) + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function adminLevelOptions(selected) {
|
||||
return [
|
||||
{ v: 'full', l: '超级管理员' },
|
||||
{ v: 'ops', l: '运营管理员' },
|
||||
].map(function (o) {
|
||||
return '<option value="' + o.v + '"' + (selected === o.v ? ' selected' : '') + '>' + o.l + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function bindAdminLevelField() {
|
||||
var roleEl = Ui().el('ueRole') || Ui().el('ucRole');
|
||||
var wrap = Ui().el('adminLevelField');
|
||||
if (!roleEl || !wrap) return;
|
||||
function sync() {
|
||||
wrap.style.display = roleEl.value === 'admin' ? '' : 'none';
|
||||
}
|
||||
roleEl.onchange = sync;
|
||||
sync();
|
||||
}
|
||||
|
||||
function showCreateModal() {
|
||||
var form = '<div class="soon-admin-form">' +
|
||||
formField('邮箱', '<input id="ucEmail" type="email" class="soon-input soon-input--sm" autocomplete="off">') +
|
||||
formField('密码', '<input id="ucPwd" type="password" class="soon-input soon-input--sm" autocomplete="new-password" placeholder="至少 8 位">') +
|
||||
formField('角色', '<select id="ucRole" class="soon-input soon-input--sm">' + roleOptions('user') + '</select>') +
|
||||
(State().adminLevel === 'full'
|
||||
? formField('管理级别', '<div id="adminLevelField" style="display:none"><select id="ucAdminLevel" class="soon-input soon-input--sm">' +
|
||||
adminLevelOptions('ops') + '</select></div>', '运营管理员不可访问设置、审计与支付密钥')
|
||||
: '') +
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">创建</button></div>';
|
||||
Ui().openFormModal('新增用户', form, function () {
|
||||
var email = Ui().el('ucEmail').value.trim();
|
||||
var pwd = Ui().el('ucPwd').value;
|
||||
if (!email || pwd.length < 8) {
|
||||
soonToast('请填写有效邮箱与至少 8 位密码', 'warn');
|
||||
return false;
|
||||
}
|
||||
var body = {
|
||||
email: email,
|
||||
password: pwd,
|
||||
role: Ui().el('ucRole').value,
|
||||
};
|
||||
if (body.role === 'admin') {
|
||||
var lv = Ui().el('ucAdminLevel');
|
||||
body.admin_level = lv ? lv.value : 'ops';
|
||||
}
|
||||
return Api().postJson('users', body).then(function (r) {
|
||||
if (r) {
|
||||
layer.closeAll();
|
||||
soonToast('用户已创建', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
}, {
|
||||
success: function () { bindAdminLevelField(); },
|
||||
});
|
||||
}
|
||||
|
||||
function showEditModal(user) {
|
||||
var level = user.admin_level || 'full';
|
||||
var form = '<div class="soon-admin-form">' +
|
||||
formField('邮箱', '<input id="ueEmail" type="email" class="soon-input soon-input--sm" value="' + I().esc(user.email) + '">') +
|
||||
formField('角色', '<select id="ueRole" class="soon-input soon-input--sm">' + roleOptions(user.role) + '</select>') +
|
||||
(State().adminLevel === 'full'
|
||||
? formField('管理级别', '<div id="adminLevelField"><select id="ueAdminLevel" class="soon-input soon-input--sm">' +
|
||||
adminLevelOptions(level) + '</select></div>', '仅对管理员账号生效')
|
||||
: '') +
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">保存</button></div>';
|
||||
Ui().openFormModal('编辑用户 #' + user.id, form, function () {
|
||||
var body = {
|
||||
email: Ui().el('ueEmail').value.trim(),
|
||||
role: Ui().el('ueRole').value,
|
||||
};
|
||||
if (body.role === 'admin') {
|
||||
var lv = Ui().el('ueAdminLevel');
|
||||
if (lv) body.admin_level = lv.value;
|
||||
}
|
||||
return Api().putJson('users/' + user.id, body).then(function (r) {
|
||||
if (r) {
|
||||
layer.closeAll();
|
||||
soonToast('用户信息已更新', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
}, {
|
||||
success: function () { bindAdminLevelField(); },
|
||||
});
|
||||
}
|
||||
|
||||
function showPasswordModal(userId) {
|
||||
var form = '<div class="soon-admin-form">' +
|
||||
formField('新密码', '<input id="upPwd" type="password" class="soon-input soon-input--sm" autocomplete="new-password" placeholder="至少 8 位">') +
|
||||
formField('确认密码', '<input id="upPwd2" type="password" class="soon-input soon-input--sm" autocomplete="new-password">') +
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">重置密码</button></div>';
|
||||
Ui().openFormModal('重置密码', form, function () {
|
||||
var p1 = Ui().el('upPwd').value;
|
||||
var p2 = Ui().el('upPwd2').value;
|
||||
if (p1.length < 8) {
|
||||
soonToast('密码至少 8 位', 'warn');
|
||||
return false;
|
||||
}
|
||||
if (p1 !== p2) {
|
||||
soonToast('两次密码不一致', 'warn');
|
||||
return false;
|
||||
}
|
||||
return Api().postJson('users/' + userId + '/password', { password: p1 }).then(function (r) {
|
||||
if (r) {
|
||||
layer.closeAll();
|
||||
soonToast('密码已重置', 'success');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showSubscriptionModal(userId) {
|
||||
Api().get('plans').then(function (j) {
|
||||
if (!j) return;
|
||||
var opts = (j.data.items || []).map(function (p) {
|
||||
return '<option value="' + p.id + '" data-days="' + p.duration_days + '">' +
|
||||
I().esc(p.name) + ' (' + p.duration_days + ' 天)</option>';
|
||||
}).join('');
|
||||
var form = '<div class="soon-admin-form">' +
|
||||
formField('套餐', '<select id="usPlan" class="soon-input soon-input--sm">' + opts + '</select>') +
|
||||
formField('有效天数', '<input id="usDays" type="number" min="0" class="soon-input soon-input--sm" placeholder="留空则用套餐默认天数">') +
|
||||
'<p class="soon-admin-form-hint">选择免费版将取消当前有效会员。</p>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="adminModalSave">保存</button></div>';
|
||||
Ui().openFormModal('设置会员', form, function () {
|
||||
var planSel = Ui().el('usPlan');
|
||||
var daysVal = Ui().el('usDays').value.trim();
|
||||
var body = { plan_id: parseInt(planSel.value, 10) };
|
||||
if (daysVal) body.days = parseInt(daysVal, 10);
|
||||
return Api().postJson('users/' + userId + '/subscription', body).then(function (r) {
|
||||
if (r) {
|
||||
layer.closeAll();
|
||||
soonToast('会员已更新', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
});
|
||||
var planEl = Ui().el('usPlan');
|
||||
if (planEl) {
|
||||
planEl.onchange = function () {
|
||||
var opt = planEl.options[planEl.selectedIndex];
|
||||
Ui().el('usDays').placeholder = '默认 ' + (opt.getAttribute('data-days') || '0') + ' 天';
|
||||
};
|
||||
planEl.onchange();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showUserDetail(id, opts) {
|
||||
opts = opts || {};
|
||||
if (!opts.fromRoute) Router().openDetail(id);
|
||||
Api().get('users/' + id).then(function (j) {
|
||||
if (!j) return;
|
||||
var d = j.data;
|
||||
var u = d.user || {};
|
||||
var sub = d.subscription;
|
||||
var usage = d.usage || {};
|
||||
var adminId = State().currentAdminId;
|
||||
|
||||
var levelLabel = u.role === 'admin'
|
||||
? (u.admin_level === 'ops' ? '运营管理员' : '超级管理员')
|
||||
: '—';
|
||||
var account = Ui().detailRow('邮箱', I().esc(u.email)) +
|
||||
Ui().detailRow('角色', I().esc(I().tRole(u.role))) +
|
||||
(u.role === 'admin' ? Ui().detailRow('管理级别', I().esc(levelLabel)) : '') +
|
||||
Ui().detailRow('状态', I().badgeStatus(u.status)) +
|
||||
Ui().detailRow('注册时间', I().esc(I().formatDateTime(u.created_at)));
|
||||
|
||||
var subHtml;
|
||||
if (sub && sub.plan_name) {
|
||||
subHtml = Ui().detailRow('套餐', I().esc(sub.plan_name)) +
|
||||
Ui().detailRow('状态', I().badgeStatus(sub.status)) +
|
||||
Ui().detailRow('开始时间', I().esc(I().formatDateTime(sub.started_at))) +
|
||||
Ui().detailRow('到期时间', I().esc(I().formatDateTime(sub.expires_at)));
|
||||
} else {
|
||||
subHtml = '<p class="soon-admin-detail-empty">免费版 / 无有效订阅</p>';
|
||||
}
|
||||
|
||||
var usageHtml = Ui().detailRow('文件数', I().esc(usage.files_count || 0)) +
|
||||
Ui().detailRow('占用空间', I().esc(I().formatBytes(usage.storage_bytes)));
|
||||
|
||||
var orderRows = (d.recent_orders || []).map(function (o) {
|
||||
return '<tr><td>' + I().esc(o.order_no || o.id) + '</td><td>' + I().formatCents(o.amount_cents) +
|
||||
'</td><td>' + I().badgeStatus(o.status) + '</td><td>' + I().formatDateTime(o.created_at) + '</td></tr>';
|
||||
}).join('');
|
||||
var ordersHtml = Ui().dataTable(['订单号', '金额', '状态', '时间'], orderRows, 4);
|
||||
|
||||
var actions = '<div class="soon-admin-drawer-actions">' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="drawerEdit">编辑</button>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="drawerPwd">改密</button>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="drawerSub">设会员</button>' +
|
||||
(u.id !== adminId ? '<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost soon-btn--danger" id="drawerDel">删除</button>' : '') +
|
||||
'</div>';
|
||||
|
||||
Ui().openDrawer('用户 #' + u.id, actions +
|
||||
Ui().detailSection('账号信息', account) +
|
||||
Ui().detailSection('当前会员', subHtml) +
|
||||
Ui().detailSection('云端用量', usageHtml) +
|
||||
Ui().detailSection('最近订单', ordersHtml));
|
||||
|
||||
Ui().el('drawerEdit').onclick = function () { Ui().closeDrawer(); showEditModal(u); };
|
||||
Ui().el('drawerPwd').onclick = function () { showPasswordModal(u.id); };
|
||||
Ui().el('drawerSub').onclick = function () { showSubscriptionModal(u.id); };
|
||||
var delBtn = Ui().el('drawerDel');
|
||||
if (delBtn) {
|
||||
delBtn.onclick = function () {
|
||||
Ui().confirm('确认删除该用户?其云端文件将标记删除且不可恢复。', function () {
|
||||
Api().del('users/' + u.id).then(function (r) {
|
||||
if (r) {
|
||||
Ui().closeDrawer();
|
||||
soonToast('用户已删除', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function userRowActions(u, adminId) {
|
||||
var isSelf = adminId && u.id === adminId;
|
||||
var editDisabled = State().adminLevel === 'ops' && u.role === 'admin';
|
||||
var ds = { id: String(u.id) };
|
||||
var editDs = {
|
||||
id: String(u.id),
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
admin_level: u.admin_level || 'full',
|
||||
};
|
||||
var inline = [
|
||||
{ label: '详情', action: 'detail', dataset: ds },
|
||||
{ label: '编辑', action: 'edit', dataset: editDs, disabled: editDisabled },
|
||||
{ label: '订阅', action: 'sub', dataset: ds },
|
||||
];
|
||||
var more = [
|
||||
{ label: '改密', action: 'pwd', dataset: ds },
|
||||
];
|
||||
if (u.status === 'disabled') {
|
||||
more.push({ label: '启用', action: 'status', dataset: { id: String(u.id), st: 'active' } });
|
||||
} else if (!isSelf) {
|
||||
more.push({ label: '停用', action: 'status', dataset: { id: String(u.id), st: 'disabled' } });
|
||||
} else {
|
||||
more.push({ label: '停用', action: 'status', disabled: true });
|
||||
}
|
||||
if (!isSelf) {
|
||||
more.push({
|
||||
label: '删除',
|
||||
action: 'delete',
|
||||
className: 'soon-admin-action-menu__item--danger',
|
||||
dataset: ds,
|
||||
});
|
||||
}
|
||||
return Menu().rowActions('user-' + u.id, inline, more);
|
||||
}
|
||||
|
||||
function bindUserMenus(panel, adminId) {
|
||||
Menu().bind(panel, function (action, btn) {
|
||||
var id = parseInt(btn.dataset.id, 10);
|
||||
if (action === 'detail') showUserDetail(id);
|
||||
if (action === 'edit') {
|
||||
showEditModal({
|
||||
id: id,
|
||||
email: btn.dataset.email,
|
||||
role: btn.dataset.role,
|
||||
admin_level: btn.dataset.adminLevel || 'full',
|
||||
});
|
||||
}
|
||||
if (action === 'pwd') showPasswordModal(id);
|
||||
if (action === 'sub') showSubscriptionModal(id);
|
||||
if (action === 'status' && btn.dataset.st) {
|
||||
Ui().confirm('确认变更用户状态?', function () {
|
||||
Api().postJson('users/' + id + '/status', { status: btn.dataset.st }).then(function (r) {
|
||||
if (r) {
|
||||
soonToast('状态已更新', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if (action === 'delete') {
|
||||
Ui().confirm('确认删除该用户?其云端文件将标记删除。', function () {
|
||||
Api().del('users/' + id).then(function (r) {
|
||||
if (r) {
|
||||
soonToast('用户已删除', 'success');
|
||||
render();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderFilterBar(st) {
|
||||
return Filter().bar(
|
||||
Filter().field('邮箱', Filter().searchInput('userSearchQ', st.q, '搜索邮箱')),
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="userSearchBtn">搜索</button>' +
|
||||
'<button type="button" class="soon-btn soon-btn--sm soon-btn--ghost" id="userSearchReset">重置</button>'
|
||||
);
|
||||
}
|
||||
|
||||
function bindFilters(st) {
|
||||
Ui().el('userSearchBtn').onclick = function () {
|
||||
st.q = Ui().el('userSearchQ').value.trim();
|
||||
st.page = 1;
|
||||
Router().syncQuery();
|
||||
render();
|
||||
};
|
||||
var reset = Ui().el('userSearchReset');
|
||||
if (reset) {
|
||||
reset.onclick = function () {
|
||||
st.q = '';
|
||||
st.page = 1;
|
||||
Router().syncQuery();
|
||||
render();
|
||||
};
|
||||
}
|
||||
Ui().el('userSearchQ').onkeydown = function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
st.q = Ui().el('userSearchQ').value.trim();
|
||||
st.page = 1;
|
||||
Router().syncQuery();
|
||||
render();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
var panel = Ui().el('panel-users');
|
||||
if (!panel) return;
|
||||
if (!Router().hasPendingDetail()) Ui().closeDrawer();
|
||||
var st = State().users;
|
||||
var pageShell = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('用户管理', '增删改用户、重置密码与设置会员',
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="userCreateBtn">新增用户</button>') +
|
||||
renderFilterBar(st) + Ui().skeletonTable() + '</div>';
|
||||
panel.innerHTML = pageShell;
|
||||
|
||||
Api().get(buildQuery()).then(function (j) {
|
||||
if (!j) {
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('用户管理', '增删改用户、重置密码与设置会员',
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="userCreateBtn">新增用户</button>') +
|
||||
renderFilterBar(st) + Ui().listError('用户列表加载失败', 'usersListRetry') + '</div>';
|
||||
Ui().el('userCreateBtn').onclick = showCreateModal;
|
||||
Ui().el('usersListRetry').onclick = render;
|
||||
bindFilters(st);
|
||||
var errDetailId = Router().consumeDetailId();
|
||||
if (errDetailId) showUserDetail(errDetailId, { fromRoute: true });
|
||||
return;
|
||||
}
|
||||
var items = j.data.items || [];
|
||||
var adminId = State().currentAdminId;
|
||||
var rows = items.map(function (u) {
|
||||
var planLabel = u.plan_name ? I().esc(u.plan_name) : '免费版';
|
||||
return '<tr><td>' + u.id + '</td><td>' + I().esc(u.email) + '</td><td>' + I().esc(I().tRole(u.role)) +
|
||||
'</td><td>' + planLabel + '</td><td>' + I().badgeStatus(u.status) + '</td><td>' +
|
||||
I().formatDateTime(u.created_at) + '</td><td class="soon-admin-table__actions">' +
|
||||
userRowActions(u, adminId) + '</td></tr>';
|
||||
}).join('');
|
||||
|
||||
panel.innerHTML = '<div class="soon-admin-page">' +
|
||||
Ui().pageHeader('用户管理', '增删改用户、重置密码与设置会员',
|
||||
'<button type="button" class="soon-btn soon-btn--sm" id="userCreateBtn">新增用户</button>') +
|
||||
renderFilterBar(st) +
|
||||
Ui().dataTable(['ID', '邮箱', '角色', '会员', '状态', '注册时间', '操作'], rows) +
|
||||
Ui().pagerHtml(j.data.page, j.data.size, j.data.total) + '</div>';
|
||||
|
||||
Ui().el('userCreateBtn').onclick = showCreateModal;
|
||||
bindFilters(st);
|
||||
Ui().bindPager(panel, j.data.page, j.data.size, j.data.total, function (p, newSize) {
|
||||
st.page = p;
|
||||
if (newSize) st.size = newSize;
|
||||
Router().syncQuery();
|
||||
render();
|
||||
});
|
||||
bindUserMenus(panel, adminId);
|
||||
var detailId = Router().consumeDetailId();
|
||||
if (detailId) showUserDetail(detailId, { fromRoute: true });
|
||||
});
|
||||
}
|
||||
|
||||
window.AdminViews = window.AdminViews || {};
|
||||
window.AdminViews.users = { render: render };
|
||||
})();
|
||||
Reference in New Issue
Block a user