feat(server): enhance skill management and error handling
Some checks failed
Deploy skills-market-server / deploy (push) Has been cancelled
Some checks failed
Deploy skills-market-server / deploy (push) Has been cancelled
- Added middleware to handle invalid JSON body errors, returning a 400 status with a descriptive message. - Introduced functions to normalize and filter mode tags for user permissions, improving access control for skills based on user roles. - Updated API endpoints to incorporate mode tag filtering, ensuring users can only access skills they are permitted to view. - Implemented a new endpoint for updating skill tags, with permission checks for non-admin users. - Enhanced the frontend with updated styles and layout adjustments for better user experience.
This commit is contained in:
parent
6ecd1e2b37
commit
f86cace07d
@ -6,18 +6,19 @@
|
||||
<title>权限管理平台</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f3f6ff;
|
||||
--bg2: #eefcf6;
|
||||
--bg: #f8fafc;
|
||||
--bg2: #eff6ff;
|
||||
--card: #ffffff;
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
--line: #e2e8f0;
|
||||
--line: #dbe3ef;
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--secondary: #64748b;
|
||||
--success: #059669;
|
||||
--danger: #dc2626;
|
||||
--warning: #d97706;
|
||||
--ring: rgba(37, 99, 235, 0.18);
|
||||
--fz-xs: 12px;
|
||||
--fz-sm: 14px;
|
||||
--fz-md: 15px;
|
||||
@ -27,18 +28,19 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", Arial, sans-serif;
|
||||
background: radial-gradient(circle at 20% 0%, #dbeafe 0%, transparent 35%),
|
||||
radial-gradient(circle at 100% 20%, #dcfce7 0%, transparent 30%),
|
||||
linear-gradient(145deg, var(--bg), var(--bg2));
|
||||
background:
|
||||
radial-gradient(circle at 12% 0%, #dbeafe 0%, transparent 32%),
|
||||
radial-gradient(circle at 100% 10%, #d1fae5 0%, transparent 26%),
|
||||
linear-gradient(155deg, var(--bg), var(--bg2));
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
font-size: var(--fz-md);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
max-width: 1360px;
|
||||
margin: 0 auto;
|
||||
padding: 30px;
|
||||
padding: 26px;
|
||||
}
|
||||
.container.auth-mode {
|
||||
min-height: calc(100vh - 60px);
|
||||
@ -58,7 +60,13 @@
|
||||
justify-content: center;
|
||||
}
|
||||
.hero {
|
||||
margin-bottom: 18px;
|
||||
margin-bottom: 14px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.72));
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.hero h2 {
|
||||
margin: 0;
|
||||
@ -74,7 +82,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
margin-top: 9px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
@ -83,13 +91,31 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
.card {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), #fff);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.97), #fff);
|
||||
border: 1px solid rgba(226, 232, 240, 0.8);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
|
||||
padding: 20px 22px;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
|
||||
padding: 18px 20px;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.section-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@ -100,40 +126,43 @@
|
||||
margin: 0 0 10px;
|
||||
font-size: var(--fz-lg);
|
||||
}
|
||||
input, button, select {
|
||||
height: 42px;
|
||||
input:not([type="checkbox"]), button, select {
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
padding: 0 14px;
|
||||
padding: 0 13px;
|
||||
font-size: var(--fz-md);
|
||||
outline: none;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
input, select {
|
||||
input:not([type="checkbox"]), select {
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
input:focus, select:focus {
|
||||
input:not([type="checkbox"]):focus, select:focus {
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.13);
|
||||
box-shadow: 0 0 0 3px var(--ring);
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
background: var(--primary);
|
||||
background: linear-gradient(180deg, #3b82f6, var(--primary));
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.24);
|
||||
}
|
||||
button:hover {
|
||||
background: var(--primary-hover);
|
||||
background: linear-gradient(180deg, #2563eb, var(--primary-hover));
|
||||
}
|
||||
button.secondary {
|
||||
background: #fff;
|
||||
border-color: var(--line);
|
||||
color: #334155;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
button.secondary:hover {
|
||||
background: #f8fafc;
|
||||
background: #f8fbff;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
@ -146,54 +175,217 @@
|
||||
#code { width: 140px; }
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 14px;
|
||||
margin-top: 10px;
|
||||
background: #fff;
|
||||
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 1050px;
|
||||
font-size: var(--fz-md);
|
||||
min-width: 1120px;
|
||||
font-size: 14px;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
padding: 12px 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
th {
|
||||
background: #f8fafc;
|
||||
background: linear-gradient(180deg, #f8fafc, #f3f7fd);
|
||||
color: #334155;
|
||||
font-size: var(--fz-xs);
|
||||
letter-spacing: 0.2px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
tbody tr:nth-child(2n) {
|
||||
background: #fcfdff;
|
||||
}
|
||||
tbody tr:hover {
|
||||
background: #f4f9ff;
|
||||
}
|
||||
tbody tr:hover { background: #f8fbff; }
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: var(--fz-xs);
|
||||
}
|
||||
.hidden { display: none; }
|
||||
.mode-list {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
.mode-picker {
|
||||
min-width: 210px;
|
||||
width: 226px;
|
||||
}
|
||||
.mode-list label {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
font-size: var(--fz-xs);
|
||||
line-height: 1;
|
||||
.mode-picker-trigger {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 42px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d7e0ee;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(180deg, #ffffff, #f8fbff);
|
||||
color: #1f2937;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
box-shadow: 0 2px 7px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
.mode-picker-trigger:hover {
|
||||
border-color: #c6d4ea;
|
||||
background: #f8fbff;
|
||||
}
|
||||
.mode-picker-trigger:focus-visible {
|
||||
outline: none;
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.13);
|
||||
}
|
||||
.mode-picker-title {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.mode-picker-value {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.mode-picker-caret {
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mode-hidden-inputs {
|
||||
display: none;
|
||||
}
|
||||
.mode-popover {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.12);
|
||||
z-index: 999;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transition: opacity 130ms ease-out;
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
.mode-popover.show {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
.mode-popover-card {
|
||||
position: fixed;
|
||||
width: 276px;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #fff, #fafdff);
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.16);
|
||||
transform: translateY(6px) scale(0.985);
|
||||
transform-origin: top left;
|
||||
opacity: 0;
|
||||
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1), opacity 150ms ease-out;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.mode-popover-card[data-side="top"] {
|
||||
transform: translateY(-6px) scale(0.985);
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
.mode-popover.show .mode-popover-card {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
.mode-popover-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 2px 4px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mode-popover-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.mode-popover-options label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
padding: 8px 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
cursor: pointer;
|
||||
transition: border-color 140ms ease, background 140ms ease;
|
||||
}
|
||||
.mode-popover-options label:hover {
|
||||
border-color: #cbd5e1;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.mode-popover-options input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: #2563eb;
|
||||
}
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
.perm-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.perm-toggle input[type="checkbox"] {
|
||||
appearance: none;
|
||||
width: 34px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: #e2e8f0;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
margin: 0;
|
||||
}
|
||||
.perm-toggle input[type="checkbox"]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18);
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
.perm-toggle input[type="checkbox"]:checked {
|
||||
border-color: #3b82f6;
|
||||
background: #3b82f6;
|
||||
}
|
||||
.perm-toggle input[type="checkbox"]:checked::after {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
.perm-toggle input[type="checkbox"]:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button[data-action="save"] {
|
||||
min-width: 66px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.danger {
|
||||
color: var(--danger);
|
||||
@ -219,7 +411,7 @@
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
@ -288,14 +480,14 @@
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.container {
|
||||
padding: 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
.container.auth-mode {
|
||||
min-height: calc(100vh - 36px);
|
||||
justify-content: center;
|
||||
}
|
||||
.card {
|
||||
padding: 16px;
|
||||
padding: 15px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.search-input {
|
||||
@ -312,13 +504,13 @@
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
.hero .sub {
|
||||
font-size: var(--fz-sm);
|
||||
}
|
||||
input, button, select {
|
||||
height: 40px;
|
||||
input:not([type="checkbox"]), button, select {
|
||||
height: 38px;
|
||||
font-size: var(--fz-sm);
|
||||
}
|
||||
#code {
|
||||
@ -347,6 +539,12 @@
|
||||
</div>
|
||||
|
||||
<div id="panel" class="card hidden" style="margin-top: 12px">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3 class="section-title">用户权限配置</h3>
|
||||
<p class="section-sub">按用户粒度设置角色、模式访问范围与功能页权限</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input id="query" class="search-input" type="text" placeholder="搜索邮箱或昵称" />
|
||||
<button id="refreshBtn" class="secondary">搜索</button>
|
||||
@ -387,8 +585,11 @@
|
||||
</div>
|
||||
|
||||
<div id="auditPanel" class="card hidden" style="margin-top: 12px">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<strong>权限变更审计日志</strong>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3 class="section-title">权限变更审计日志</h3>
|
||||
<p class="section-sub">最近 50 条权限调整记录,便于追踪治理行为</p>
|
||||
</div>
|
||||
<button id="refreshAuditBtn" class="secondary">刷新日志</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
@ -409,9 +610,26 @@
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast info"></div>
|
||||
<div id="modePopover" class="mode-popover hidden" aria-hidden="true">
|
||||
<div id="modePopoverCard" class="mode-popover-card">
|
||||
<div class="mode-popover-header">
|
||||
<span>可用模式</span>
|
||||
<button id="modePopoverClose" class="secondary" style="height: 28px; padding: 0 10px">完成</button>
|
||||
</div>
|
||||
<div id="modePopoverOptions" class="mode-popover-options"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ALL_MODES = ['chat', 'clarify', 'cowork', 'video', 'code']
|
||||
const ALL_MODES = ['chat', 'clarify', 'cowork', 'create', 'video', 'code']
|
||||
const MODE_LABELS = {
|
||||
chat: '对话',
|
||||
clarify: '澄清',
|
||||
cowork: '协作',
|
||||
create: '创作',
|
||||
video: '视频',
|
||||
code: '代码'
|
||||
}
|
||||
let token = localStorage.getItem('admin_token') || ''
|
||||
const containerEl = document.querySelector('.container')
|
||||
const loginCardEl = document.getElementById('loginCard')
|
||||
@ -426,6 +644,83 @@
|
||||
canUseDeveloperMode: true
|
||||
}
|
||||
}
|
||||
const modePopover = document.getElementById('modePopover')
|
||||
const modePopoverCard = document.getElementById('modePopoverCard')
|
||||
const modePopoverOptions = document.getElementById('modePopoverOptions')
|
||||
const modePopoverClose = document.getElementById('modePopoverClose')
|
||||
let activeModePicker = null
|
||||
let modePopoverHideTimer = null
|
||||
|
||||
function summarizeSelectedModes(selected, available) {
|
||||
if (!available || available.length === 0) return '未授权'
|
||||
if (selected.length === 0) return '未选择'
|
||||
if (selected.length === available.length) return `全部(${selected.length})`
|
||||
const labels = selected.map((m) => MODE_LABELS[m] || m)
|
||||
if (labels.length <= 2) return labels.join(' / ')
|
||||
return `${labels.slice(0, 2).join(' / ')} +${labels.length - 2}`
|
||||
}
|
||||
|
||||
function setModePopoverVisible(visible) {
|
||||
if (!modePopover) return
|
||||
if (modePopoverHideTimer) {
|
||||
clearTimeout(modePopoverHideTimer)
|
||||
modePopoverHideTimer = null
|
||||
}
|
||||
if (visible) {
|
||||
modePopover.classList.remove('hidden')
|
||||
modePopover.classList.add('show')
|
||||
modePopover.setAttribute('aria-hidden', 'false')
|
||||
return
|
||||
}
|
||||
modePopover.classList.remove('show')
|
||||
modePopover.setAttribute('aria-hidden', 'true')
|
||||
modePopoverHideTimer = setTimeout(() => {
|
||||
modePopover.classList.add('hidden')
|
||||
}, 180)
|
||||
}
|
||||
|
||||
function closeModePopover() {
|
||||
setModePopoverVisible(false)
|
||||
activeModePicker = null
|
||||
}
|
||||
|
||||
function positionModePopover(triggerEl) {
|
||||
if (!triggerEl || !modePopoverCard) return
|
||||
const rect = triggerEl.getBoundingClientRect()
|
||||
const cardWidth = 276
|
||||
const cardHeight = 208
|
||||
const spacing = 8
|
||||
let left = Math.max(8, Math.min(rect.left, window.innerWidth - cardWidth - 8))
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const openTop = spaceBelow < cardHeight + spacing && rect.top > cardHeight + spacing
|
||||
let top = openTop ? rect.top - cardHeight - spacing : rect.bottom + spacing
|
||||
top = Math.max(8, Math.min(top, window.innerHeight - cardHeight - 8))
|
||||
modePopoverCard.style.left = `${left}px`
|
||||
modePopoverCard.style.top = `${top}px`
|
||||
modePopoverCard.setAttribute('data-side', openTop ? 'top' : 'bottom')
|
||||
}
|
||||
|
||||
function openModePopover(triggerEl, rowEl, availableModes) {
|
||||
if (!modePopoverOptions || !rowEl) return
|
||||
activeModePicker = { triggerEl, rowEl, availableModes }
|
||||
const selectedModes = Array.from(rowEl.querySelectorAll('input[data-mode]'))
|
||||
.filter((el) => el.checked)
|
||||
.map((el) => el.dataset.mode)
|
||||
modePopoverOptions.innerHTML = availableModes
|
||||
.map((mode) => {
|
||||
const checked = selectedModes.includes(mode) ? 'checked' : ''
|
||||
const id = `mode-popover-${rowEl.dataset.id}-${mode}`
|
||||
return `
|
||||
<label for="${id}">
|
||||
<input id="${id}" type="checkbox" data-popover-mode="${mode}" ${checked} />
|
||||
<span>${MODE_LABELS[mode] || mode}</span>
|
||||
</label>
|
||||
`
|
||||
})
|
||||
.join('')
|
||||
positionModePopover(triggerEl)
|
||||
setModePopoverVisible(true)
|
||||
}
|
||||
|
||||
function api(path, options = {}) {
|
||||
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
||||
@ -446,7 +741,7 @@
|
||||
|
||||
function boolCell(name, checked, enabled) {
|
||||
if (!enabled) return '<span class="muted">未授权</span>'
|
||||
return `<input type="checkbox" data-perm="${name}" ${checked ? 'checked' : ''} />`
|
||||
return `<label class="perm-toggle"><input type="checkbox" data-perm="${name}" ${checked ? 'checked' : ''} /></label>`
|
||||
}
|
||||
|
||||
function renderAuditLogs(logs) {
|
||||
@ -488,13 +783,13 @@
|
||||
.map((u) => {
|
||||
const p = u.permissions || {}
|
||||
const allowedModes = Array.isArray(p.allowedModes) ? p.allowedModes : []
|
||||
const modeChecks = ALL_MODES
|
||||
const selectableModes = ALL_MODES
|
||||
.filter((m) => grantableModes.includes(m))
|
||||
.map(
|
||||
(m) =>
|
||||
`<label><input type="checkbox" data-mode="${m}" ${allowedModes.includes(m) ? 'checked' : ''}/> ${m}</label>`
|
||||
)
|
||||
.join(' ')
|
||||
.join('')
|
||||
const editable = !!u.editable
|
||||
const roleOptions = [
|
||||
`<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option>`,
|
||||
@ -510,7 +805,7 @@
|
||||
return `
|
||||
<tr data-id="${u.id}">
|
||||
<td>
|
||||
<div>${u.nickname || '-'}</div>
|
||||
<div class="user-name">${u.nickname || '-'}</div>
|
||||
<div class="muted">${u.email}</div>
|
||||
</td>
|
||||
<td>
|
||||
@ -520,7 +815,25 @@
|
||||
</select>
|
||||
${roleHint}
|
||||
</td>
|
||||
<td><div class="mode-list">${modeChecks || '<span class="muted">未授权</span>'}</div></td>
|
||||
<td>
|
||||
${
|
||||
selectableModes
|
||||
? `<div class="mode-picker">
|
||||
<button type="button" class="mode-picker-trigger" data-action="open-modes">
|
||||
<span class="mode-picker-title">可用模式</span>
|
||||
<span class="mode-picker-value" data-mode-summary>
|
||||
${summarizeSelectedModes(
|
||||
ALL_MODES.filter((m) => grantableModes.includes(m) && allowedModes.includes(m)),
|
||||
ALL_MODES.filter((m) => grantableModes.includes(m))
|
||||
)}
|
||||
</span>
|
||||
<span class="mode-picker-caret">▾</span>
|
||||
</button>
|
||||
<div class="mode-hidden-inputs">${selectableModes}</div>
|
||||
</div>`
|
||||
: '<span class="muted">未授权</span>'
|
||||
}
|
||||
</td>
|
||||
<td>${boolCell('canViewSkillsPage', !!p.canViewSkillsPage, !!grantable.canViewSkillsPage)}</td>
|
||||
<td>${boolCell('canViewAgentsPage', !!p.canViewAgentsPage, !!grantable.canViewAgentsPage)}</td>
|
||||
<td>${boolCell('canUseSshPage', !!p.canUseSshPage, !!grantable.canUseSshPage)}</td>
|
||||
@ -634,11 +947,26 @@
|
||||
setLoggedInState(false)
|
||||
})
|
||||
document.getElementById('tbody').addEventListener('click', async (e) => {
|
||||
if (!(e.target instanceof HTMLButtonElement)) return
|
||||
if (e.target.dataset.action !== 'save') return
|
||||
const tr = e.target.closest('tr')
|
||||
const id = tr?.dataset.id
|
||||
if (!id || !tr) return
|
||||
const target = e.target
|
||||
if (!(target instanceof Element)) return
|
||||
const tr = target.closest('tr')
|
||||
if (!tr) return
|
||||
const editable = !tr.querySelector('button[data-action="save"]')?.disabled
|
||||
|
||||
const modeTrigger = target.closest('button[data-action="open-modes"]')
|
||||
if (modeTrigger instanceof HTMLButtonElement) {
|
||||
if (!editable) return
|
||||
const modeInputs = Array.from(tr.querySelectorAll('input[data-mode]'))
|
||||
const availableModes = modeInputs.map((el) => el.dataset.mode).filter(Boolean)
|
||||
if (availableModes.length === 0) return
|
||||
openModePopover(modeTrigger, tr, availableModes)
|
||||
return
|
||||
}
|
||||
|
||||
const saveBtn = target.closest('button[data-action="save"]')
|
||||
if (!(saveBtn instanceof HTMLButtonElement)) return
|
||||
const id = tr.dataset.id
|
||||
if (!id) return
|
||||
const payload = readRowPayload(tr)
|
||||
const data = await api(`/api/admin/users/${id}/permissions`, {
|
||||
method: 'PATCH',
|
||||
@ -652,6 +980,37 @@
|
||||
await loadUsers()
|
||||
await loadAuditLogs()
|
||||
})
|
||||
|
||||
modePopoverOptions?.addEventListener('change', (e) => {
|
||||
const target = e.target
|
||||
if (!(target instanceof HTMLInputElement)) return
|
||||
if (target.dataset.popoverMode == null || !activeModePicker) return
|
||||
const { rowEl, availableModes } = activeModePicker
|
||||
const selectedModes = Array.from(modePopoverOptions.querySelectorAll('input[data-popover-mode]'))
|
||||
.filter((el) => el.checked)
|
||||
.map((el) => el.dataset.popoverMode)
|
||||
rowEl.querySelectorAll('input[data-mode]').forEach((el) => {
|
||||
const mode = el.dataset.mode
|
||||
el.checked = !!mode && selectedModes.includes(mode)
|
||||
})
|
||||
const summaryEl = rowEl.querySelector('[data-mode-summary]')
|
||||
if (summaryEl) {
|
||||
summaryEl.textContent = summarizeSelectedModes(selectedModes, availableModes)
|
||||
}
|
||||
})
|
||||
|
||||
modePopoverClose?.addEventListener('click', () => closeModePopover())
|
||||
modePopover?.addEventListener('click', (e) => {
|
||||
if (e.target === modePopover) closeModePopover()
|
||||
})
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && modePopover && modePopover.classList.contains('show')) {
|
||||
closeModePopover()
|
||||
}
|
||||
})
|
||||
window.addEventListener('resize', () => {
|
||||
if (activeModePicker) positionModePopover(activeModePicker.triggerEl)
|
||||
})
|
||||
document.getElementById('query').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') loadUsers()
|
||||
})
|
||||
|
||||
@ -22,7 +22,7 @@ const WHITELIST_EMAILS = (process.env.WHITELIST_EMAILS || '')
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
|
||||
const ALL_MODES = ['chat', 'clarify', 'cowork', 'video', 'code']
|
||||
const ALL_MODES = ['chat', 'clarify', 'cowork', 'create', 'video', 'code']
|
||||
|
||||
function getDefaultPermissions() {
|
||||
return {
|
||||
@ -48,9 +48,22 @@ function sanitizePermissions(raw) {
|
||||
const defaults = getDefaultPermissions()
|
||||
if (!raw || typeof raw !== 'object') return defaults
|
||||
const next = { ...defaults }
|
||||
const allowedModes = Array.isArray(raw.allowedModes)
|
||||
let allowedModes = Array.isArray(raw.allowedModes)
|
||||
? raw.allowedModes.filter((m) => ALL_MODES.includes(m))
|
||||
: defaults.allowedModes
|
||||
// Backward compatibility for old data where cowork/create were merged.
|
||||
if (allowedModes.includes('cowork') && !allowedModes.includes('create')) {
|
||||
const idx = allowedModes.indexOf('cowork')
|
||||
if (idx >= 0) {
|
||||
allowedModes = [
|
||||
...allowedModes.slice(0, idx + 1),
|
||||
'create',
|
||||
...allowedModes.slice(idx + 1)
|
||||
]
|
||||
} else {
|
||||
allowedModes = [...allowedModes, 'create']
|
||||
}
|
||||
}
|
||||
next.allowedModes = allowedModes.length > 0 ? allowedModes : ['chat']
|
||||
next.canViewSkillsPage = !!raw.canViewSkillsPage
|
||||
next.canViewAgentsPage = !!raw.canViewAgentsPage
|
||||
|
||||
207
server.js
207
server.js
@ -15,6 +15,13 @@ app.use(cors())
|
||||
app.use(express.json({ limit: '50mb' }))
|
||||
app.use('/updates', express.static(UPDATES_DIR))
|
||||
app.use('/admin', express.static(path.join(__dirname, 'admin-web')))
|
||||
app.use((err, req, res, next) => {
|
||||
if (err instanceof SyntaxError && 'body' in err) {
|
||||
console.error('[API] Invalid JSON body:', req.originalUrl, err.message)
|
||||
return res.status(400).json({ success: false, error: 'Invalid JSON body' })
|
||||
}
|
||||
next(err)
|
||||
})
|
||||
|
||||
let db
|
||||
let skillsCollection
|
||||
@ -137,6 +144,62 @@ function sameUserId(a, b) {
|
||||
return lhs.length > 0 && rhs.length > 0 && lhs === rhs
|
||||
}
|
||||
|
||||
function normalizeTagList(tags) {
|
||||
if (!Array.isArray(tags)) return []
|
||||
const set = new Set()
|
||||
const out = []
|
||||
for (const raw of tags) {
|
||||
const tag = String(raw || '').trim().toLowerCase()
|
||||
if (!tag || tag === 'chat' || set.has(tag)) continue
|
||||
set.add(tag)
|
||||
out.push(tag)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function getAllowedModeTagsFromUser(user) {
|
||||
const modes = Array.isArray(user?.permissions?.allowedModes) ? user.permissions.allowedModes : []
|
||||
return normalizeTagList(modes)
|
||||
}
|
||||
|
||||
function buildModeIntersectionFilter(user, requestedTags = []) {
|
||||
const allowedTags = getAllowedModeTagsFromUser(user)
|
||||
const requested = normalizeTagList(requestedTags)
|
||||
const isRootAdmin = !!user?.is_root_admin
|
||||
const isManagementList = requested.length === 0
|
||||
const effective =
|
||||
requested.length > 0
|
||||
? requested.filter((tag) => allowedTags.includes(tag))
|
||||
: allowedTags
|
||||
|
||||
if (isRootAdmin && isManagementList) {
|
||||
if (effective.length === 0) {
|
||||
return { $or: [{ tags: { $exists: false } }, { tags: { $size: 0 } }] }
|
||||
}
|
||||
return {
|
||||
$or: [
|
||||
{ tags: { $in: effective } },
|
||||
{ tags: { $exists: false } },
|
||||
{ tags: { $size: 0 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
if (effective.length === 0) {
|
||||
return { _id: { $exists: false } }
|
||||
}
|
||||
return { tags: { $in: effective } }
|
||||
}
|
||||
|
||||
function canUserAccessTaggedResource(user, resourceTags) {
|
||||
const allowedTags = getAllowedModeTagsFromUser(user)
|
||||
const tags = normalizeTagList(resourceTags)
|
||||
const isRootAdmin = !!user?.is_root_admin
|
||||
if (tags.length === 0) return isRootAdmin
|
||||
if (allowedTags.length === 0) return false
|
||||
return tags.some((tag) => allowedTags.includes(tag))
|
||||
}
|
||||
|
||||
function extractDescription(files) {
|
||||
const skillFile = files.find(f => f.path === 'SKILL.md' || f.path.endsWith('SKILL.md'))
|
||||
if (!skillFile) return ''
|
||||
@ -207,17 +270,28 @@ app.use('/api/agents', requirePermission('canViewAgentsPage', '智能体页面')
|
||||
|
||||
app.get('/api/skills', async (req, res) => {
|
||||
try {
|
||||
const { query, offset = 0, limit = 50 } = req.query
|
||||
|
||||
let filter = { is_public: PUBLIC_VISIBILITY_FILTER }
|
||||
const { query, offset = 0, limit = 50, modeTag } = req.query
|
||||
const requestedTags = modeTag ? [modeTag] : []
|
||||
const baseFilter = {
|
||||
is_public: PUBLIC_VISIBILITY_FILTER,
|
||||
...buildModeIntersectionFilter(req.user, requestedTags)
|
||||
}
|
||||
let filter = baseFilter
|
||||
if (query && query.trim()) {
|
||||
const q = query.trim().toLowerCase()
|
||||
filter.$or = [
|
||||
filter = {
|
||||
$and: [
|
||||
baseFilter,
|
||||
{
|
||||
$or: [
|
||||
{ name: { $regex: q, $options: 'i' } },
|
||||
{ description: { $regex: q, $options: 'i' } },
|
||||
{ owner: { $regex: q, $options: 'i' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const total = await skillsCollection.countDocuments(filter)
|
||||
const skills = await skillsCollection
|
||||
@ -264,7 +338,7 @@ app.get('/api/skills/:name', async (req, res) => {
|
||||
is_public: PUBLIC_VISIBILITY_FILTER
|
||||
})
|
||||
|
||||
if (!skill) {
|
||||
if (!skill || !canUserAccessTaggedResource(req.user, skill.tags)) {
|
||||
return res.status(404).json({ success: false, error: 'Skill not found' })
|
||||
}
|
||||
|
||||
@ -296,7 +370,7 @@ app.get('/api/skills/:name/download', async (req, res) => {
|
||||
is_public: PUBLIC_VISIBILITY_FILTER
|
||||
})
|
||||
|
||||
if (!skill) {
|
||||
if (!skill || !canUserAccessTaggedResource(req.user, skill.tags)) {
|
||||
return res.status(404).json({ success: false, error: 'Skill not found' })
|
||||
}
|
||||
|
||||
@ -329,7 +403,7 @@ app.post('/api/skills/:name/execute', requireAuth(async (req, res) => {
|
||||
is_public: PUBLIC_VISIBILITY_FILTER
|
||||
})
|
||||
|
||||
if (!skill) {
|
||||
if (!skill || !canUserAccessTaggedResource(req.user, skill.tags)) {
|
||||
return res.status(404).json({ success: false, error: 'Skill not found' })
|
||||
}
|
||||
|
||||
@ -397,7 +471,7 @@ app.get('/api/skills/:name/files/*', async (req, res) => {
|
||||
is_public: PUBLIC_VISIBILITY_FILTER
|
||||
})
|
||||
|
||||
if (!skill) {
|
||||
if (!skill || !canUserAccessTaggedResource(req.user, skill.tags)) {
|
||||
return res.status(404).json({ success: false, error: 'Skill not found' })
|
||||
}
|
||||
|
||||
@ -472,9 +546,10 @@ app.delete('/api/skills/:name/lock', async (req, res) => {
|
||||
app.get('/api/skills/mine', async (req, res, next) => {
|
||||
authRoutes.verifyToken(req, res, async () => {
|
||||
try {
|
||||
const modeFilter = buildModeIntersectionFilter(req.user)
|
||||
const skills = await skillsCollection
|
||||
.find(
|
||||
{ $or: [{ owner: req.user.id }, { owner: 'system' }] },
|
||||
{ $and: [{ $or: [{ owner: req.user.id }, { owner: 'system' }] }, modeFilter] },
|
||||
{
|
||||
projection: {
|
||||
name: 1,
|
||||
@ -512,6 +587,37 @@ app.get('/api/skills/mine', async (req, res, next) => {
|
||||
})
|
||||
})
|
||||
|
||||
app.patch('/api/skills/:name/tags', async (req, res) => {
|
||||
authRoutes.verifyToken(req, res, async () => {
|
||||
try {
|
||||
if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewSkillsPage')) {
|
||||
return res.status(403).json({ success: false, error: '无权修改技能标签' })
|
||||
}
|
||||
const allowedModeTags = getAllowedModeTagsFromUser(req.user)
|
||||
const nextTags = normalizeTagList(req.body?.tags).filter((tag) => allowedModeTags.includes(tag))
|
||||
const now = new Date()
|
||||
const result = await skillsCollection.findOneAndUpdate(
|
||||
{ name: req.params.name },
|
||||
{
|
||||
$set: {
|
||||
tags: nextTags,
|
||||
updated_at: now,
|
||||
updated_by: req.user.id,
|
||||
updated_by_nickname: req.user.nickname || req.user.email
|
||||
}
|
||||
},
|
||||
{ returnDocument: 'after' }
|
||||
)
|
||||
if (!result) {
|
||||
return res.status(404).json({ success: false, error: 'Skill not found' })
|
||||
}
|
||||
res.json({ success: true, tags: result.tags || [] })
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/skills/:name/publish
|
||||
*
|
||||
@ -537,6 +643,8 @@ app.post('/api/skills/:name/publish', async (req, res) => {
|
||||
const { files, description, tags, localModifiedAt, replaceAll } = req.body
|
||||
const userId = req.user.id
|
||||
const userNickname = req.user.nickname || req.user.email
|
||||
const allowedModeTags = getAllowedModeTagsFromUser(req.user)
|
||||
const normalizedTags = normalizeTagList(tags).filter((tag) => allowedModeTags.includes(tag))
|
||||
|
||||
if (!files || !Array.isArray(files) || files.length === 0) {
|
||||
return res.status(400).json({ success: false, error: 'No files provided' })
|
||||
@ -599,7 +707,7 @@ app.post('/api/skills/:name/publish', async (req, res) => {
|
||||
updated_at: now,
|
||||
updated_by: userId,
|
||||
updated_by_nickname: userNickname,
|
||||
tags: tags || existingSkill.tags || []
|
||||
tags: normalizedTags.length > 0 ? normalizedTags : (existingSkill.tags || [])
|
||||
},
|
||||
$push: { versions: versionEntry }
|
||||
}
|
||||
@ -624,7 +732,7 @@ app.post('/api/skills/:name/publish', async (req, res) => {
|
||||
files,
|
||||
downloads: 0,
|
||||
is_public: true,
|
||||
tags: tags || [],
|
||||
tags: normalizedTags,
|
||||
versions: [{
|
||||
version: 1,
|
||||
description: 'Initial version',
|
||||
@ -773,15 +881,27 @@ app.get('/api/stats', async (req, res) => {
|
||||
|
||||
app.get('/api/agents', async (req, res) => {
|
||||
try {
|
||||
const { query, offset = 0, limit = 100 } = req.query
|
||||
let filter = { is_public: PUBLIC_VISIBILITY_FILTER }
|
||||
const { query, offset = 0, limit = 100, modeTag } = req.query
|
||||
const requestedTags = modeTag ? [modeTag] : []
|
||||
const baseFilter = {
|
||||
is_public: PUBLIC_VISIBILITY_FILTER,
|
||||
...buildModeIntersectionFilter(req.user, requestedTags)
|
||||
}
|
||||
let filter = baseFilter
|
||||
if (query && query.trim()) {
|
||||
const q = query.trim()
|
||||
filter.$or = [
|
||||
filter = {
|
||||
$and: [
|
||||
baseFilter,
|
||||
{
|
||||
$or: [
|
||||
{ name: { $regex: q, $options: 'i' } },
|
||||
{ description: { $regex: q, $options: 'i' } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
const total = await agentsCollection.countDocuments(filter)
|
||||
const agents = await agentsCollection
|
||||
.find(filter, { projection: { name: 1, description: 1, owner: 1, updated_at: 1, tags: 1 } })
|
||||
@ -802,8 +922,12 @@ app.get('/api/agents', async (req, res) => {
|
||||
app.get('/api/agents/mine', async (req, res, next) => {
|
||||
authRoutes.verifyToken(req, res, async () => {
|
||||
try {
|
||||
const modeFilter = buildModeIntersectionFilter(req.user)
|
||||
const agents = await agentsCollection
|
||||
.find({ $or: [{ owner: req.user.id }, { owner: 'system' }] }, { projection: { name: 1, description: 1, is_public: 1, updated_at: 1, lock: 1, owner: 1 } })
|
||||
.find(
|
||||
{ $and: [{ $or: [{ owner: req.user.id }, { owner: 'system' }] }, modeFilter] },
|
||||
{ projection: { name: 1, description: 1, is_public: 1, updated_at: 1, lock: 1, owner: 1, tags: 1 } }
|
||||
)
|
||||
.sort({ updated_at: -1 })
|
||||
.toArray()
|
||||
res.json({
|
||||
@ -816,6 +940,7 @@ app.get('/api/agents/mine', async (req, res, next) => {
|
||||
owner: a.owner,
|
||||
is_public: a.is_public !== false,
|
||||
updated_at: a.updated_at,
|
||||
tags: a.tags || [],
|
||||
lock: getActiveLock(a)
|
||||
}))
|
||||
})
|
||||
@ -828,7 +953,9 @@ app.get('/api/agents/mine', async (req, res, next) => {
|
||||
app.get('/api/agents/:name', async (req, res) => {
|
||||
try {
|
||||
const agent = await agentsCollection.findOne({ name: req.params.name, is_public: PUBLIC_VISIBILITY_FILTER })
|
||||
if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' })
|
||||
if (!agent || !canUserAccessTaggedResource(req.user, agent.tags)) {
|
||||
return res.status(404).json({ success: false, error: 'Agent not found' })
|
||||
}
|
||||
res.json({ success: true, agent: { id: agent._id, name: agent.name, description: agent.description, owner: agent.owner, content: agent.content, updated_at: agent.updated_at } })
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message })
|
||||
@ -841,9 +968,11 @@ app.post('/api/agents/:name/publish', async (req, res) => {
|
||||
if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewAgentsPage')) {
|
||||
return res.status(403).json({ success: false, error: '无权发布或修改智能体' })
|
||||
}
|
||||
const { content, description, localModifiedAt } = req.body
|
||||
const { content, description, tags, localModifiedAt } = req.body
|
||||
const userId = req.user.id
|
||||
const userNickname = req.user.nickname || req.user.email
|
||||
const allowedModeTags = getAllowedModeTagsFromUser(req.user)
|
||||
const normalizedTags = normalizeTagList(tags).filter((tag) => allowedModeTags.includes(tag))
|
||||
if (!content) return res.status(400).json({ success: false, error: 'No content provided' })
|
||||
|
||||
const agentName = req.params.name.toLowerCase().replace(/[^a-z0-9-]/g, '-')
|
||||
@ -874,7 +1003,17 @@ app.post('/api/agents/:name/publish', async (req, res) => {
|
||||
const versionEntry = { version: (existing.versions?.length || 0) + 1, content: existing.content, created_at: now, created_by: userId, created_by_nickname: userNickname }
|
||||
await agentsCollection.updateOne(
|
||||
{ _id: existing._id },
|
||||
{ $set: { content, description: description || existing.description, updated_at: now, updated_by: userId, updated_by_nickname: userNickname }, $push: { versions: versionEntry } }
|
||||
{
|
||||
$set: {
|
||||
content,
|
||||
description: description || existing.description,
|
||||
updated_at: now,
|
||||
updated_by: userId,
|
||||
updated_by_nickname: userNickname,
|
||||
tags: normalizedTags.length > 0 ? normalizedTags : (existing.tags || [])
|
||||
},
|
||||
$push: { versions: versionEntry }
|
||||
}
|
||||
)
|
||||
res.json({ success: true, action: 'updated', name: agentName, version: versionEntry.version })
|
||||
} else {
|
||||
@ -885,6 +1024,7 @@ app.post('/api/agents/:name/publish', async (req, res) => {
|
||||
owner_nickname: userNickname,
|
||||
content,
|
||||
is_public: true,
|
||||
tags: normalizedTags,
|
||||
versions: [{ version: 1, content, created_at: now, created_by: userId, created_by_nickname: userNickname }],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
@ -902,6 +1042,37 @@ app.post('/api/agents/:name/publish', async (req, res) => {
|
||||
})
|
||||
})
|
||||
|
||||
app.patch('/api/agents/:name/tags', async (req, res) => {
|
||||
authRoutes.verifyToken(req, res, async () => {
|
||||
try {
|
||||
if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewAgentsPage')) {
|
||||
return res.status(403).json({ success: false, error: '无权修改智能体标签' })
|
||||
}
|
||||
const allowedModeTags = getAllowedModeTagsFromUser(req.user)
|
||||
const nextTags = normalizeTagList(req.body?.tags).filter((tag) => allowedModeTags.includes(tag))
|
||||
const now = new Date()
|
||||
const result = await agentsCollection.findOneAndUpdate(
|
||||
{ name: req.params.name },
|
||||
{
|
||||
$set: {
|
||||
tags: nextTags,
|
||||
updated_at: now,
|
||||
updated_by: req.user.id,
|
||||
updated_by_nickname: req.user.nickname || req.user.email
|
||||
}
|
||||
},
|
||||
{ returnDocument: 'after' }
|
||||
)
|
||||
if (!result) {
|
||||
return res.status(404).json({ success: false, error: 'Agent not found' })
|
||||
}
|
||||
res.json({ success: true, tags: result.tags || [] })
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/api/agents/:name', requireAdmin(async (req, res) => {
|
||||
try {
|
||||
const agent = await agentsCollection.findOne({ name: req.params.name })
|
||||
|
||||
@ -140,8 +140,8 @@
|
||||
<div class="step-title">下载安装包</div>
|
||||
<div class="step-desc">根据您的 Mac 芯片选择对应版本:</div>
|
||||
<div>
|
||||
<a id="mac-arm64" href="likecowork-1.1.0-arm64-mac.zip" class="download-btn">Apple Silicon (M1/M2/M3)</a>
|
||||
<a id="mac-x64" href="likecowork-1.1.0-x64-mac.zip" class="download-btn">Intel 芯片</a>
|
||||
<a id="mac-arm64" href="https://oss.xintiao85.com/updates/likecowork-1.2.1-arm64-mac.zip" class="download-btn">Apple Silicon (M1/M2/M3)</a>
|
||||
<a id="mac-x64" href="https://oss.xintiao85.com/updates/likecowork-1.2.1-x64-mac.zip" class="download-btn">Intel 芯片</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -180,7 +180,7 @@
|
||||
<div class="step-title">下载安装包</div>
|
||||
<div class="step-desc"></div>
|
||||
<div>
|
||||
<a id="win-setup" href="likecowork-1.1.0-setup.exe" class="download-btn">下载 Windows 安装包</a>
|
||||
<a id="win-setup" href="https://oss.xintiao85.com/updates/likecowork-1.2.1-setup.exe" class="download-btn">下载 Windows 安装包</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -215,13 +215,15 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const OSS_UPDATES_BASE = 'https://oss.xintiao85.com/updates';
|
||||
|
||||
// 动态获取最新版本
|
||||
async function fetchLatestVersion() {
|
||||
try {
|
||||
// 使用绝对路径,避免不同访问路径下的相对路径偏差
|
||||
// 固定从对象存储读取最新元数据
|
||||
const [macRes, winRes] = await Promise.all([
|
||||
fetch('/updates/latest-mac.yml'),
|
||||
fetch('/updates/latest.yml')
|
||||
fetch(`${OSS_UPDATES_BASE}/latest-mac.yml`),
|
||||
fetch(`${OSS_UPDATES_BASE}/latest.yml`)
|
||||
]);
|
||||
|
||||
if (!macRes.ok) {
|
||||
@ -232,8 +234,8 @@
|
||||
if (macMatch) {
|
||||
const version = macMatch[1];
|
||||
document.getElementById('version-mac').textContent = `(v${version})`;
|
||||
document.getElementById('mac-arm64').href = `likecowork-${version}-arm64-mac.zip`;
|
||||
document.getElementById('mac-x64').href = `likecowork-${version}-x64-mac.zip`;
|
||||
document.getElementById('mac-arm64').href = `${OSS_UPDATES_BASE}/likecowork-${version}-arm64-mac.zip`;
|
||||
document.getElementById('mac-x64').href = `${OSS_UPDATES_BASE}/likecowork-${version}-x64-mac.zip`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -245,7 +247,7 @@
|
||||
if (winMatch) {
|
||||
const version = winMatch[1];
|
||||
document.getElementById('version-win').textContent = `(v${version})`;
|
||||
document.getElementById('win-setup').href = `likecowork-${version}-setup.exe`;
|
||||
document.getElementById('win-setup').href = `${OSS_UPDATES_BASE}/likecowork-${version}-setup.exe`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user