Compare commits

...

6 Commits

Author SHA1 Message Date
f86cace07d feat(server): enhance skill management and error handling
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.
2026-03-30 16:10:31 +08:00
6ecd1e2b37 feat(auth): support multiple root admin emails and enhance admin login verification
- Updated the environment variable configuration to allow multiple root admin emails via `ADMIN_EMAILS`, while maintaining compatibility with the legacy `ADMIN_EMAIL`.
- Modified the admin login verification process to check against the list of root admin emails.
- Enhanced the admin role management in the frontend to reflect the new multiple admin structure.
2026-03-27 17:11:05 +08:00
hj
0a84043cc2 feat(updates): 更新应用版本至1.2.1并替换安装包
删除旧版本1.1.2的安装包及配置文件
添加新版本1.2.1的Windows和Mac平台安装包
更新latest.yml和latest-mac.yml版本配置
2026-03-27 16:41:18 +08:00
89eaad88ec refactor(server): simplify user ID comparison for locks
- Introduced a new utility function `sameUserId` to streamline user ID comparisons across multiple API endpoints.
- Updated lock validation logic in the skill and agent management routes to utilize the new function, enhancing code readability and maintainability.
2026-03-27 15:31:03 +08:00
abcd0a53d3 feat(server): enhance skill and agent import functionality
- Added support for clearing version history during skill and agent imports with the `--clear-versions` flag.
- Updated import scripts to handle versioning more effectively, ensuring only the latest version is retained when clearing history.
- Introduced new checks for user permissions in various API endpoints to restrict access based on roles.
- Normalized file paths during merges to ensure consistency across different operating systems.
- Updated `.gitignore` to exclude the `scripts/` directory.
2026-03-27 11:08:56 +08:00
57bd558b69 fix: 重构发版部署方式 2026-03-24 18:59:27 +08:00
10 changed files with 1203 additions and 171 deletions

View File

@ -4,6 +4,8 @@ DB_NAME=skills_market
JWT_SECRET=your-jwt-secret-key-change-in-production JWT_SECRET=your-jwt-secret-key-change-in-production
JWT_EXPIRES_IN=7d JWT_EXPIRES_IN=7d
# Root admins (comma-separated). Legacy ADMIN_EMAIL is still supported.
ADMIN_EMAILS=admin@example.com,ops@example.com
ADMIN_EMAIL=admin@example.com ADMIN_EMAIL=admin@example.com
# 登录白名单:固定验证码,不发邮件,多个邮箱用逗号分隔 # 登录白名单:固定验证码,不发邮件,多个邮箱用逗号分隔

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
node_modules/ node_modules/
.env .env
*.log *.log
scripts/

View File

@ -61,7 +61,7 @@ npm run dev
- 登录方式:管理员邮箱验证码登录(`/api/auth/send-code` + `/api/admin/login` - 登录方式:管理员邮箱验证码登录(`/api/auth/send-code` + `/api/admin/login`
- 管理能力:用户列表、角色切换、权限配置(模式可见性 / 技能页 / 智能体页 / SSH 页 / 开发者模式) - 管理能力:用户列表、角色切换、权限配置(模式可见性 / 技能页 / 智能体页 / SSH 页 / 开发者模式)
- 相关接口:`/api/admin/login``/api/admin/users``/api/admin/users/:userId/permissions``/api/admin/audit-logs` - 相关接口:`/api/admin/login``/api/admin/users``/api/admin/users/:userId/permissions``/api/admin/audit-logs`
- 权限规则:`ADMIN_EMAIL` 为初始管理员root admin);其他管理员只能编辑非 admin 用户,且不能修改自己的权限 - 权限规则:`ADMIN_EMAILS`逗号分隔定义初始管理员root admin兼容旧的 `ADMIN_EMAIL`);其他管理员只能编辑非 admin 用户,且不能修改自己的权限
## 环境变量 ## 环境变量

View File

@ -6,18 +6,19 @@
<title>权限管理平台</title> <title>权限管理平台</title>
<style> <style>
:root { :root {
--bg: #f3f6ff; --bg: #f8fafc;
--bg2: #eefcf6; --bg2: #eff6ff;
--card: #ffffff; --card: #ffffff;
--text: #0f172a; --text: #0f172a;
--muted: #64748b; --muted: #64748b;
--line: #e2e8f0; --line: #dbe3ef;
--primary: #2563eb; --primary: #2563eb;
--primary-hover: #1d4ed8; --primary-hover: #1d4ed8;
--secondary: #64748b; --secondary: #64748b;
--success: #059669; --success: #059669;
--danger: #dc2626; --danger: #dc2626;
--warning: #d97706; --warning: #d97706;
--ring: rgba(37, 99, 235, 0.18);
--fz-xs: 12px; --fz-xs: 12px;
--fz-sm: 14px; --fz-sm: 14px;
--fz-md: 15px; --fz-md: 15px;
@ -27,18 +28,19 @@
body { body {
margin: 0; margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", Arial, sans-serif; font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", Arial, sans-serif;
background: radial-gradient(circle at 20% 0%, #dbeafe 0%, transparent 35%), background:
radial-gradient(circle at 100% 20%, #dcfce7 0%, transparent 30%), radial-gradient(circle at 12% 0%, #dbeafe 0%, transparent 32%),
linear-gradient(145deg, var(--bg), var(--bg2)); radial-gradient(circle at 100% 10%, #d1fae5 0%, transparent 26%),
linear-gradient(155deg, var(--bg), var(--bg2));
color: var(--text); color: var(--text);
min-height: 100vh; min-height: 100vh;
font-size: var(--fz-md); font-size: var(--fz-md);
line-height: 1.55; line-height: 1.55;
} }
.container { .container {
max-width: 1280px; max-width: 1360px;
margin: 0 auto; margin: 0 auto;
padding: 30px; padding: 26px;
} }
.container.auth-mode { .container.auth-mode {
min-height: calc(100vh - 60px); min-height: calc(100vh - 60px);
@ -58,7 +60,13 @@
justify-content: center; justify-content: center;
} }
.hero { .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 { .hero h2 {
margin: 0; margin: 0;
@ -74,7 +82,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
margin-top: 10px; margin-top: 9px;
padding: 5px 12px; padding: 5px 12px;
border-radius: 999px; border-radius: 999px;
background: rgba(37, 99, 235, 0.1); background: rgba(37, 99, 235, 0.1);
@ -83,13 +91,31 @@
font-weight: 600; font-weight: 600;
} }
.card { .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: 1px solid rgba(226, 232, 240, 0.8);
border-radius: 16px; border-radius: 18px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06); box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
padding: 20px 22px; padding: 18px 20px;
backdrop-filter: blur(5px); 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 { .row {
display: flex; display: flex;
gap: 12px; gap: 12px;
@ -100,40 +126,43 @@
margin: 0 0 10px; margin: 0 0 10px;
font-size: var(--fz-lg); font-size: var(--fz-lg);
} }
input, button, select { input:not([type="checkbox"]), button, select {
height: 42px; height: 40px;
border-radius: 12px; border-radius: 12px;
border: 1px solid var(--line); border: 1px solid var(--line);
padding: 0 14px; padding: 0 13px;
font-size: var(--fz-md); font-size: var(--fz-md);
outline: none; outline: none;
transition: all 0.18s ease; transition: all 0.18s ease;
} }
input, select { input:not([type="checkbox"]), select {
background: #fff; background: #fff;
color: var(--text); 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; border-color: #93c5fd;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.13); box-shadow: 0 0 0 3px var(--ring);
} }
button { button {
cursor: pointer; cursor: pointer;
border: 1px solid transparent; border: 1px solid transparent;
background: var(--primary); background: linear-gradient(180deg, #3b82f6, var(--primary));
color: #fff; color: #fff;
font-weight: 600; font-weight: 600;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.24);
} }
button:hover { button:hover {
background: var(--primary-hover); background: linear-gradient(180deg, #2563eb, var(--primary-hover));
} }
button.secondary { button.secondary {
background: #fff; background: #fff;
border-color: var(--line); border-color: var(--line);
color: #334155; color: #334155;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
} }
button.secondary:hover { button.secondary:hover {
background: #f8fafc; background: #f8fbff;
} }
button:disabled { button:disabled {
opacity: 0.6; opacity: 0.6;
@ -146,54 +175,217 @@
#code { width: 140px; } #code { width: 140px; }
.table-wrap { .table-wrap {
overflow: auto; overflow: auto;
border: 1px solid #e5e7eb; border: 1px solid #dbe4f0;
border-radius: 12px; border-radius: 14px;
margin-top: 10px; margin-top: 10px;
background: #fff; background: #fff;
box-shadow: inset 0 1px 0 rgba(148, 163, 184, 0.08);
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
min-width: 1050px; min-width: 1120px;
font-size: var(--fz-md); font-size: 14px;
} }
th, td { th, td {
border-bottom: 1px solid #f1f5f9; border-bottom: 1px solid #edf2f7;
padding: 12px 14px; padding: 12px 12px;
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
white-space: nowrap; white-space: nowrap;
} }
th { th {
background: #f8fafc; background: linear-gradient(180deg, #f8fafc, #f3f7fd);
color: #334155; color: #334155;
font-size: var(--fz-xs); font-size: var(--fz-xs);
letter-spacing: 0.2px; letter-spacing: 0.2px;
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; 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 { .muted {
color: var(--muted); color: var(--muted);
font-size: var(--fz-xs); font-size: var(--fz-xs);
} }
.hidden { display: none; } .hidden { display: none; }
.mode-list { .mode-picker {
display: flex; min-width: 210px;
gap: 6px; width: 226px;
flex-wrap: wrap;
} }
.mode-list label { .mode-picker-trigger {
padding: 6px 10px; width: 100%;
border-radius: 999px; height: auto;
background: #f1f5f9; min-height: 42px;
border: 1px solid #e2e8f0; padding: 8px 10px;
font-size: var(--fz-xs); border: 1px solid #d7e0ee;
line-height: 1; 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; display: inline-flex;
align-items: center; 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 { .danger {
color: var(--danger); color: var(--danger);
@ -219,7 +411,7 @@
} }
.stats { .stats {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px; gap: 10px;
margin-top: 12px; margin-top: 12px;
} }
@ -288,14 +480,14 @@
} }
@media (max-width: 960px) { @media (max-width: 960px) {
.container { .container {
padding: 18px; padding: 16px;
} }
.container.auth-mode { .container.auth-mode {
min-height: calc(100vh - 36px); min-height: calc(100vh - 36px);
justify-content: center; justify-content: center;
} }
.card { .card {
padding: 16px; padding: 15px;
border-radius: 14px; border-radius: 14px;
} }
.search-input { .search-input {
@ -312,13 +504,13 @@
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.container { .container {
padding: 12px; padding: 10px;
} }
.hero .sub { .hero .sub {
font-size: var(--fz-sm); font-size: var(--fz-sm);
} }
input, button, select { input:not([type="checkbox"]), button, select {
height: 40px; height: 38px;
font-size: var(--fz-sm); font-size: var(--fz-sm);
} }
#code { #code {
@ -347,6 +539,12 @@
</div> </div>
<div id="panel" class="card hidden" style="margin-top: 12px"> <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"> <div class="row">
<input id="query" class="search-input" type="text" placeholder="搜索邮箱或昵称" /> <input id="query" class="search-input" type="text" placeholder="搜索邮箱或昵称" />
<button id="refreshBtn" class="secondary">搜索</button> <button id="refreshBtn" class="secondary">搜索</button>
@ -387,8 +585,11 @@
</div> </div>
<div id="auditPanel" class="card hidden" style="margin-top: 12px"> <div id="auditPanel" class="card hidden" style="margin-top: 12px">
<div class="row" style="justify-content: space-between"> <div class="section-head">
<strong>权限变更审计日志</strong> <div>
<h3 class="section-title">权限变更审计日志</h3>
<p class="section-sub">最近 50 条权限调整记录,便于追踪治理行为</p>
</div>
<button id="refreshAuditBtn" class="secondary">刷新日志</button> <button id="refreshAuditBtn" class="secondary">刷新日志</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
@ -409,9 +610,26 @@
</div> </div>
<div id="toast" class="toast info"></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> <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') || '' let token = localStorage.getItem('admin_token') || ''
const containerEl = document.querySelector('.container') const containerEl = document.querySelector('.container')
const loginCardEl = document.getElementById('loginCard') const loginCardEl = document.getElementById('loginCard')
@ -426,6 +644,83 @@
canUseDeveloperMode: true 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 = {}) { function api(path, options = {}) {
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) } const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }
@ -446,7 +741,7 @@
function boolCell(name, checked, enabled) { function boolCell(name, checked, enabled) {
if (!enabled) return '<span class="muted">未授权</span>' 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) { function renderAuditLogs(logs) {
@ -482,12 +777,13 @@
document.getElementById('userCount').textContent = String(users.length || 0) document.getElementById('userCount').textContent = String(users.length || 0)
document.getElementById('editableCount').textContent = String(users.filter((u) => !!u.editable).length) document.getElementById('editableCount').textContent = String(users.filter((u) => !!u.editable).length)
const grantable = adminCapabilities.grantable_permissions || {} const grantable = adminCapabilities.grantable_permissions || {}
const canEditAdmins = !!adminCapabilities.canEditAdmins
const grantableModes = Array.isArray(grantable.allowedModes) ? grantable.allowedModes : [] const grantableModes = Array.isArray(grantable.allowedModes) ? grantable.allowedModes : []
tbody.innerHTML = users tbody.innerHTML = users
.map((u) => { .map((u) => {
const p = u.permissions || {} const p = u.permissions || {}
const allowedModes = Array.isArray(p.allowedModes) ? p.allowedModes : [] const allowedModes = Array.isArray(p.allowedModes) ? p.allowedModes : []
const modeChecks = ALL_MODES const selectableModes = ALL_MODES
.filter((m) => grantableModes.includes(m)) .filter((m) => grantableModes.includes(m))
.map( .map(
(m) => (m) =>
@ -495,21 +791,49 @@
) )
.join('') .join('')
const editable = !!u.editable const editable = !!u.editable
const roleOptions = [
`<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option>`,
...(u.role === 'admin' || canEditAdmins
? [`<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>`]
: [])
].join('')
const roleHint =
!canEditAdmins && u.role !== 'admin'
? '<div class="muted">仅初始管理员可授予 admin</div>'
: ''
return ` return `
<tr data-id="${u.id}"> <tr data-id="${u.id}">
<td> <td>
<div>${u.nickname || '-'}</div> <div class="user-name">${u.nickname || '-'}</div>
<div class="muted">${u.email}</div> <div class="muted">${u.email}</div>
</td> </td>
<td> <td>
<div class="role ${u.role === 'admin' ? 'role-admin' : 'role-user'}">${u.role}</div> <div class="role ${u.role === 'admin' ? 'role-admin' : 'role-user'}">${u.role}</div>
<select data-field="role" ${editable ? '' : 'disabled'}> <select data-field="role" ${editable ? '' : 'disabled'}>
<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option> ${roleOptions}
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>
</select> </select>
${roleHint}
</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>
<td><div class="mode-list">${modeChecks || '<span class="muted">未授权</span>'}</div></td>
<td>${boolCell('canViewSkillsPage', !!p.canViewSkillsPage, !!grantable.canViewSkillsPage)}</td> <td>${boolCell('canViewSkillsPage', !!p.canViewSkillsPage, !!grantable.canViewSkillsPage)}</td>
<td>${boolCell('canViewAgentsPage', !!p.canViewAgentsPage, !!grantable.canViewAgentsPage)}</td> <td>${boolCell('canViewAgentsPage', !!p.canViewAgentsPage, !!grantable.canViewAgentsPage)}</td>
<td>${boolCell('canUseSshPage', !!p.canUseSshPage, !!grantable.canUseSshPage)}</td> <td>${boolCell('canUseSshPage', !!p.canUseSshPage, !!grantable.canUseSshPage)}</td>
@ -559,7 +883,7 @@
} }
const data = await api('/api/auth/send-code', { const data = await api('/api/auth/send-code', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email }) body: JSON.stringify({ email, adminOnly: true })
}) })
if (!data.success) { if (!data.success) {
notify(data.error || '发送验证码失败', 'error') notify(data.error || '发送验证码失败', 'error')
@ -623,11 +947,26 @@
setLoggedInState(false) setLoggedInState(false)
}) })
document.getElementById('tbody').addEventListener('click', async (e) => { document.getElementById('tbody').addEventListener('click', async (e) => {
if (!(e.target instanceof HTMLButtonElement)) return const target = e.target
if (e.target.dataset.action !== 'save') return if (!(target instanceof Element)) return
const tr = e.target.closest('tr') const tr = target.closest('tr')
const id = tr?.dataset.id if (!tr) return
if (!id || !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 payload = readRowPayload(tr)
const data = await api(`/api/admin/users/${id}/permissions`, { const data = await api(`/api/admin/users/${id}/permissions`, {
method: 'PATCH', method: 'PATCH',
@ -641,6 +980,37 @@
await loadUsers() await loadUsers()
await loadAuditLogs() 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) => { document.getElementById('query').addEventListener('keydown', (e) => {
if (e.key === 'Enter') loadUsers() if (e.key === 'Enter') loadUsers()
}) })

View File

@ -4,7 +4,17 @@ const { sendVerificationCode, verifyCode } = require('../services/auth')
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production' const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d' const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'
const ROOT_ADMIN_EMAIL = (process.env.ADMIN_EMAIL || '').trim().toLowerCase() const ROOT_ADMIN_EMAILS = Array.from(
new Set(
[
...(process.env.ADMIN_EMAILS || '')
.split(',')
.map((e) => e.trim().toLowerCase())
.filter(Boolean),
(process.env.ADMIN_EMAIL || '').trim().toLowerCase()
].filter(Boolean)
)
)
const WHITELIST_CODE = process.env.WHITELIST_CODE || '888888' const WHITELIST_CODE = process.env.WHITELIST_CODE || '888888'
const WHITELIST_EMAILS = (process.env.WHITELIST_EMAILS || '') const WHITELIST_EMAILS = (process.env.WHITELIST_EMAILS || '')
@ -12,7 +22,7 @@ const WHITELIST_EMAILS = (process.env.WHITELIST_EMAILS || '')
.map((e) => e.trim().toLowerCase()) .map((e) => e.trim().toLowerCase())
.filter(Boolean) .filter(Boolean)
const ALL_MODES = ['chat', 'clarify', 'cowork', 'video', 'code'] const ALL_MODES = ['chat', 'clarify', 'cowork', 'create', 'video', 'code']
function getDefaultPermissions() { function getDefaultPermissions() {
return { return {
@ -38,9 +48,22 @@ function sanitizePermissions(raw) {
const defaults = getDefaultPermissions() const defaults = getDefaultPermissions()
if (!raw || typeof raw !== 'object') return defaults if (!raw || typeof raw !== 'object') return defaults
const next = { ...defaults } const next = { ...defaults }
const allowedModes = Array.isArray(raw.allowedModes) let allowedModes = Array.isArray(raw.allowedModes)
? raw.allowedModes.filter((m) => ALL_MODES.includes(m)) ? raw.allowedModes.filter((m) => ALL_MODES.includes(m))
: defaults.allowedModes : 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.allowedModes = allowedModes.length > 0 ? allowedModes : ['chat']
next.canViewSkillsPage = !!raw.canViewSkillsPage next.canViewSkillsPage = !!raw.canViewSkillsPage
next.canViewAgentsPage = !!raw.canViewAgentsPage next.canViewAgentsPage = !!raw.canViewAgentsPage
@ -54,7 +77,8 @@ function normalizeRole(role) {
} }
function isRootAdminEmail(email) { function isRootAdminEmail(email) {
return !!ROOT_ADMIN_EMAIL && String(email || '').toLowerCase() === ROOT_ADMIN_EMAIL const emailLower = String(email || '').toLowerCase()
return emailLower.length > 0 && ROOT_ADMIN_EMAILS.includes(emailLower)
} }
function sanitizeUserForClient(userDoc) { function sanitizeUserForClient(userDoc) {
@ -170,13 +194,14 @@ function createAuthRoutes(db) {
return { return {
async ensureAdminBootstrap() { async ensureAdminBootstrap() {
try { try {
if (!ROOT_ADMIN_EMAIL) return if (ROOT_ADMIN_EMAILS.length === 0) return
const now = new Date() const now = new Date()
const existing = await usersCollection.findOne({ email: ROOT_ADMIN_EMAIL }) for (const rootEmail of ROOT_ADMIN_EMAILS) {
const existing = await usersCollection.findOne({ email: rootEmail })
if (!existing) { if (!existing) {
await usersCollection.insertOne({ await usersCollection.insertOne({
email: ROOT_ADMIN_EMAIL, email: rootEmail,
nickname: ROOT_ADMIN_EMAIL.split('@')[0], nickname: rootEmail.split('@')[0],
avatar: null, avatar: null,
role: 'admin', role: 'admin',
permissions: getAdminDefaultPermissions(), permissions: getAdminDefaultPermissions(),
@ -185,8 +210,8 @@ function createAuthRoutes(db) {
updated_at: now, updated_at: now,
last_login: null last_login: null
}) })
console.log('[Auth] Root admin bootstrap user created') console.log('[Auth] Root admin bootstrap user created:', rootEmail)
return continue
} }
await usersCollection.updateOne( await usersCollection.updateOne(
{ _id: existing._id }, { _id: existing._id },
@ -201,7 +226,8 @@ function createAuthRoutes(db) {
} }
} }
) )
console.log('[Auth] Root admin bootstrap user ensured') console.log('[Auth] Root admin bootstrap user ensured:', rootEmail)
}
} catch (err) { } catch (err) {
console.error('[Auth] ensureAdminBootstrap error:', err) console.error('[Auth] ensureAdminBootstrap error:', err)
} }
@ -209,14 +235,24 @@ function createAuthRoutes(db) {
async sendCode(req, res) { async sendCode(req, res) {
try { try {
const { email } = req.body const { email, adminOnly } = req.body
if (!email) { if (!email) {
return res.status(400).json({ success: false, error: '邮箱不能为空' }) return res.status(400).json({ success: false, error: '邮箱不能为空' })
} }
if (WHITELIST_EMAILS.includes(email.toLowerCase())) { const emailLower = String(email).trim().toLowerCase()
if (adminOnly === true) {
const user = await usersCollection.findOne({ email: emailLower })
const canLoginAdmin = !!(user && isAdmin(user))
if (!canLoginAdmin) {
return res.status(403).json({ success: false, error: '仅管理员可获取登录验证码' })
}
}
if (WHITELIST_EMAILS.includes(emailLower)) {
return res.json({ success: true, message: '验证码已发送' }) return res.json({ success: true, message: '验证码已发送' })
} }
const result = await sendVerificationCode(db, email.toLowerCase()) const result = await sendVerificationCode(db, emailLower)
if (!result.success) { if (!result.success) {
return res.status(400).json(result) return res.status(400).json(result)
} }

View File

@ -1,6 +1,8 @@
/** /**
* aiscri-xiong/resources/skills resources/agents 导入 MongoDB * aiscri-xiong/resources/skills resources/agents 导入 MongoDB
* 用法node scripts/import-resources.js [--owner <name>] [--dry-run] * 用法node scripts/import-resources.js [--owner <name>] [--dry-run] [--clear-versions]
*
* --clear-versions 更新已有 skill/agent 时清空历史 versions只保留本次导入的一条记录适合修复超大文档或重置历史
*/ */
require('dotenv').config({ path: require('path').join(__dirname, '../.env') }) require('dotenv').config({ path: require('path').join(__dirname, '../.env') })
@ -11,11 +13,13 @@ const { MongoClient } = require('mongodb')
const MONGO_URL = process.env.MONGO_URL || 'mongodb://localhost:27017' const MONGO_URL = process.env.MONGO_URL || 'mongodb://localhost:27017'
const DB_NAME = process.env.DB_NAME || 'skills_market' const DB_NAME = process.env.DB_NAME || 'skills_market'
const MAX_VERSION_HISTORY = Number.parseInt(process.env.MAX_VERSION_HISTORY || '10', 10)
const args = process.argv.slice(2) const args = process.argv.slice(2)
const ownerIdx = args.indexOf('--owner') const ownerIdx = args.indexOf('--owner')
const OWNER = ownerIdx !== -1 ? args[ownerIdx + 1] : 'system' const OWNER = ownerIdx !== -1 ? args[ownerIdx + 1] : 'system'
const DRY_RUN = args.includes('--dry-run') const DRY_RUN = args.includes('--dry-run')
const CLEAR_VERSIONS = args.includes('--clear-versions')
// 路径指向 aiscri-xiong/resources // 路径指向 aiscri-xiong/resources
const RESOURCES_DIR = path.join(__dirname, '../../aiscri-xiong/resources') const RESOURCES_DIR = path.join(__dirname, '../../aiscri-xiong/resources')
@ -54,7 +58,8 @@ function extractFrontmatter(content) {
// ── 技能导入 ──────────────────────────────────────────────────────────────── // ── 技能导入 ────────────────────────────────────────────────────────────────
async function importSkills(skillsCollection) { async function importSkills(skillsCollection, options) {
const { clearVersions } = options
if (!fs.existsSync(SKILLS_DIR)) { if (!fs.existsSync(SKILLS_DIR)) {
console.log(`[skills] 目录不存在:${SKILLS_DIR}`) console.log(`[skills] 目录不存在:${SKILLS_DIR}`)
return return
@ -84,15 +89,39 @@ async function importSkills(skillsCollection) {
const existing = await skillsCollection.findOne({ name }) const existing = await skillsCollection.findOne({ name })
if (DRY_RUN) { if (DRY_RUN) {
console.log(` [dry-run] ${existing ? '更新' : '新建'} skill: ${name} (${files.length} 个文件)`) const note = existing && clearVersions ? ' [将清空旧 versions]' : ''
console.log(` [dry-run] ${existing ? '更新' : '新建'} skill: ${name} (${files.length} 个文件)${note}`)
continue continue
} }
if (existing) { if (existing) {
if (clearVersions) {
const versionEntry = {
version: 1,
description: `Imported from resources by ${OWNER} (history cleared)`,
file_count: files.length,
created_at: now,
created_by: OWNER
}
await skillsCollection.updateOne(
{ _id: existing._id },
{
$set: {
files,
description: description || existing.description,
updated_at: now,
updated_by: OWNER,
versions: [versionEntry]
}
}
)
console.log(` [更新] skill: ${name} → 已清空历史,仅保留 v1`)
} else {
const versionEntry = { const versionEntry = {
version: (existing.versions?.length || 0) + 1, version: (existing.versions?.length || 0) + 1,
description: `Imported from resources by ${OWNER}`, description: `Imported from resources by ${OWNER}`,
files: existing.files, // Keep version snapshots lightweight to avoid MongoDB 16MB document limit.
file_count: Array.isArray(existing.files) ? existing.files.length : 0,
created_at: now, created_at: now,
created_by: OWNER created_by: OWNER
} }
@ -100,10 +129,16 @@ async function importSkills(skillsCollection) {
{ _id: existing._id }, { _id: existing._id },
{ {
$set: { files, description: description || existing.description, updated_at: now, updated_by: OWNER }, $set: { files, description: description || existing.description, updated_at: now, updated_by: OWNER },
$push: { versions: versionEntry } $push: {
versions: {
$each: [versionEntry],
$slice: -MAX_VERSION_HISTORY
}
}
} }
) )
console.log(` [更新] skill: ${name} → v${versionEntry.version}`) console.log(` [更新] skill: ${name} → v${versionEntry.version}`)
}
} else { } else {
await skillsCollection.insertOne({ await skillsCollection.insertOne({
name, name,
@ -113,7 +148,13 @@ async function importSkills(skillsCollection) {
downloads: 0, downloads: 0,
is_public: true, is_public: true,
tags: [], tags: [],
versions: [{ version: 1, description: 'Initial import', files, created_at: now, created_by: OWNER }], versions: [{
version: 1,
description: 'Initial import',
file_count: files.length,
created_at: now,
created_by: OWNER
}],
created_at: now, created_at: now,
updated_at: now, updated_at: now,
created_by: OWNER, created_by: OWNER,
@ -126,7 +167,8 @@ async function importSkills(skillsCollection) {
// ── Agent 导入 ──────────────────────────────────────────────────────────── // ── Agent 导入 ────────────────────────────────────────────────────────────
async function importAgents(agentsCollection) { async function importAgents(agentsCollection, options) {
const { clearVersions } = options
if (!fs.existsSync(AGENTS_DIR)) { if (!fs.existsSync(AGENTS_DIR)) {
console.log(`[agents] 目录不存在:${AGENTS_DIR}`) console.log(`[agents] 目录不存在:${AGENTS_DIR}`)
return return
@ -148,14 +190,37 @@ async function importAgents(agentsCollection) {
const existing = await agentsCollection.findOne({ name }) const existing = await agentsCollection.findOne({ name })
if (DRY_RUN) { if (DRY_RUN) {
console.log(` [dry-run] ${existing ? '更新' : '新建'} agent: ${name}`) const note = existing && clearVersions ? ' [将清空旧 versions]' : ''
console.log(` [dry-run] ${existing ? '更新' : '新建'} agent: ${name}${note}`)
continue continue
} }
if (existing) { if (existing) {
if (clearVersions) {
const versionEntry = {
version: 1,
content_length: content.length,
created_at: now,
created_by: OWNER,
note: 'history cleared on import'
}
await agentsCollection.updateOne(
{ _id: existing._id },
{
$set: {
content,
description: description || existing.description,
updated_at: now,
updated_by: OWNER,
versions: [versionEntry]
}
}
)
console.log(` [更新] agent: ${name} → 已清空历史,仅保留 v1`)
} else {
const versionEntry = { const versionEntry = {
version: (existing.versions?.length || 0) + 1, version: (existing.versions?.length || 0) + 1,
content: existing.content, content_length: typeof existing.content === 'string' ? existing.content.length : 0,
created_at: now, created_at: now,
created_by: OWNER created_by: OWNER
} }
@ -163,10 +228,16 @@ async function importAgents(agentsCollection) {
{ _id: existing._id }, { _id: existing._id },
{ {
$set: { content, description: description || existing.description, updated_at: now, updated_by: OWNER }, $set: { content, description: description || existing.description, updated_at: now, updated_by: OWNER },
$push: { versions: versionEntry } $push: {
versions: {
$each: [versionEntry],
$slice: -MAX_VERSION_HISTORY
}
}
} }
) )
console.log(` [更新] agent: ${name} → v${versionEntry.version}`) console.log(` [更新] agent: ${name} → v${versionEntry.version}`)
}
} else { } else {
await agentsCollection.insertOne({ await agentsCollection.insertOne({
name, name,
@ -174,7 +245,12 @@ async function importAgents(agentsCollection) {
owner: OWNER, owner: OWNER,
content, content,
is_public: true, is_public: true,
versions: [{ version: 1, content, created_at: now, created_by: OWNER }], versions: [{
version: 1,
content_length: content.length,
created_at: now,
created_by: OWNER
}],
created_at: now, created_at: now,
updated_at: now, updated_at: now,
created_by: OWNER, created_by: OWNER,
@ -192,6 +268,7 @@ async function main() {
console.log(`DB: ${DB_NAME}`) console.log(`DB: ${DB_NAME}`)
console.log(`OWNER: ${OWNER}`) console.log(`OWNER: ${OWNER}`)
console.log(`DRY_RUN: ${DRY_RUN}`) console.log(`DRY_RUN: ${DRY_RUN}`)
console.log(`CLEAR_VERSIONS: ${CLEAR_VERSIONS}`)
const client = new MongoClient(MONGO_URL) const client = new MongoClient(MONGO_URL)
await client.connect() await client.connect()
@ -199,8 +276,9 @@ async function main() {
const skillsCollection = db.collection('skills') const skillsCollection = db.collection('skills')
const agentsCollection = db.collection('agents') const agentsCollection = db.collection('agents')
await importSkills(skillsCollection) const importOpts = { clearVersions: CLEAR_VERSIONS }
await importAgents(agentsCollection) await importSkills(skillsCollection, importOpts)
await importAgents(agentsCollection, importOpts)
await client.close() await client.close()
console.log('\n✅ 导入完成') console.log('\n✅ 导入完成')

295
server.js
View File

@ -15,6 +15,13 @@ app.use(cors())
app.use(express.json({ limit: '50mb' })) app.use(express.json({ limit: '50mb' }))
app.use('/updates', express.static(UPDATES_DIR)) app.use('/updates', express.static(UPDATES_DIR))
app.use('/admin', express.static(path.join(__dirname, 'admin-web'))) 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 db
let skillsCollection let skillsCollection
@ -95,6 +102,33 @@ function injectConfidentialityInstruction(files) {
}) })
} }
/** Normalize path keys for merge (forward slashes). */
function normalizeSkillPath(p) {
return String(p || '').replace(/\\/g, '/').replace(/^\/+/, '')
}
/**
* Merge incoming file entries over existing by path. Incoming wins on collision.
* Paths only present on the server are kept so a client can send only SKILL.md
* without wiping sibling scripts/assets unless replaceAll is used on publish.
*/
function mergeSkillFilesByPath(existingFiles, incomingFiles) {
const map = new Map()
for (const f of existingFiles || []) {
if (f && f.path != null) {
const key = normalizeSkillPath(f.path)
map.set(key, { ...f, path: key })
}
}
for (const f of incomingFiles || []) {
if (f && f.path != null) {
const key = normalizeSkillPath(f.path)
map.set(key, { ...f, path: key })
}
}
return Array.from(map.values())
}
const LOCK_TTL_MS = 5 * 60 * 1000 // 5 minutes const LOCK_TTL_MS = 5 * 60 * 1000 // 5 minutes
const PUBLIC_VISIBILITY_FILTER = { $ne: false } const PUBLIC_VISIBILITY_FILTER = { $ne: false }
@ -104,6 +138,68 @@ function getActiveLock(doc) {
return { userId: doc.lock.userId, by: doc.lock.nickname || doc.lock.by || doc.lock.userId, at: doc.lock.at } return { userId: doc.lock.userId, by: doc.lock.nickname || doc.lock.by || doc.lock.userId, at: doc.lock.at }
} }
function sameUserId(a, b) {
const lhs = String(a ?? '').trim()
const rhs = String(b ?? '').trim()
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) { function extractDescription(files) {
const skillFile = files.find(f => f.path === 'SKILL.md' || f.path.endsWith('SKILL.md')) const skillFile = files.find(f => f.path === 'SKILL.md' || f.path.endsWith('SKILL.md'))
if (!skillFile) return '' if (!skillFile) return ''
@ -174,17 +270,28 @@ app.use('/api/agents', requirePermission('canViewAgentsPage', '智能体页面')
app.get('/api/skills', async (req, res) => { app.get('/api/skills', async (req, res) => {
try { try {
const { query, offset = 0, limit = 50 } = req.query const { query, offset = 0, limit = 50, modeTag } = req.query
const requestedTags = modeTag ? [modeTag] : []
let filter = { is_public: PUBLIC_VISIBILITY_FILTER } const baseFilter = {
is_public: PUBLIC_VISIBILITY_FILTER,
...buildModeIntersectionFilter(req.user, requestedTags)
}
let filter = baseFilter
if (query && query.trim()) { if (query && query.trim()) {
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
filter.$or = [ filter = {
$and: [
baseFilter,
{
$or: [
{ name: { $regex: q, $options: 'i' } }, { name: { $regex: q, $options: 'i' } },
{ description: { $regex: q, $options: 'i' } }, { description: { $regex: q, $options: 'i' } },
{ owner: { $regex: q, $options: 'i' } } { owner: { $regex: q, $options: 'i' } }
] ]
} }
]
}
}
const total = await skillsCollection.countDocuments(filter) const total = await skillsCollection.countDocuments(filter)
const skills = await skillsCollection const skills = await skillsCollection
@ -231,7 +338,7 @@ app.get('/api/skills/:name', async (req, res) => {
is_public: PUBLIC_VISIBILITY_FILTER 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' }) return res.status(404).json({ success: false, error: 'Skill not found' })
} }
@ -263,7 +370,7 @@ app.get('/api/skills/:name/download', async (req, res) => {
is_public: PUBLIC_VISIBILITY_FILTER 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' }) return res.status(404).json({ success: false, error: 'Skill not found' })
} }
@ -296,7 +403,7 @@ app.post('/api/skills/:name/execute', requireAuth(async (req, res) => {
is_public: PUBLIC_VISIBILITY_FILTER 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' }) return res.status(404).json({ success: false, error: 'Skill not found' })
} }
@ -364,7 +471,7 @@ app.get('/api/skills/:name/files/*', async (req, res) => {
is_public: PUBLIC_VISIBILITY_FILTER 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' }) return res.status(404).json({ success: false, error: 'Skill not found' })
} }
@ -388,12 +495,15 @@ app.get('/api/skills/:name/files/*', async (req, res) => {
app.post('/api/skills/:name/lock', async (req, res) => { app.post('/api/skills/:name/lock', async (req, res) => {
authRoutes.verifyToken(req, res, async () => { authRoutes.verifyToken(req, res, async () => {
try { try {
if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewSkillsPage')) {
return res.status(403).json({ success: false, error: '无权编辑技能' })
}
const skill = await skillsCollection.findOne({ name: req.params.name }) const skill = await skillsCollection.findOne({ name: req.params.name })
if (!skill) { if (!skill) {
return res.status(404).json({ success: false, error: 'Skill not found' }) return res.status(404).json({ success: false, error: 'Skill not found' })
} }
const activeLock = getActiveLock(skill) const activeLock = getActiveLock(skill)
if (activeLock && activeLock.userId !== req.user.id) { if (activeLock && !sameUserId(activeLock.userId, req.user.id)) {
return res.status(423).json({ success: false, error: `${activeLock.by} 正在编辑`, locked_by: activeLock.by }) return res.status(423).json({ success: false, error: `${activeLock.by} 正在编辑`, locked_by: activeLock.by })
} }
await skillsCollection.updateOne( await skillsCollection.updateOne(
@ -410,13 +520,16 @@ app.post('/api/skills/:name/lock', async (req, res) => {
app.delete('/api/skills/:name/lock', async (req, res) => { app.delete('/api/skills/:name/lock', async (req, res) => {
authRoutes.verifyToken(req, res, async () => { authRoutes.verifyToken(req, res, async () => {
try { try {
if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewSkillsPage')) {
return res.status(403).json({ success: false, error: '无权编辑技能' })
}
const skill = await skillsCollection.findOne({ name: req.params.name }) const skill = await skillsCollection.findOne({ name: req.params.name })
if (!skill) { if (!skill) {
return res.status(404).json({ success: false, error: 'Skill not found' }) return res.status(404).json({ success: false, error: 'Skill not found' })
} }
const activeLock = getActiveLock(skill) const activeLock = getActiveLock(skill)
const isAdmin = req.user.role === 'admin' const isAdmin = req.user.role === 'admin'
if (activeLock && activeLock.userId !== req.user.id && !isAdmin) { if (activeLock && !sameUserId(activeLock.userId, req.user.id) && !isAdmin) {
return res.status(403).json({ success: false, error: '只能由加锁用户或管理员解锁' }) return res.status(403).json({ success: false, error: '只能由加锁用户或管理员解锁' })
} }
await skillsCollection.updateOne( await skillsCollection.updateOne(
@ -433,9 +546,10 @@ app.delete('/api/skills/:name/lock', async (req, res) => {
app.get('/api/skills/mine', async (req, res, next) => { app.get('/api/skills/mine', async (req, res, next) => {
authRoutes.verifyToken(req, res, async () => { authRoutes.verifyToken(req, res, async () => {
try { try {
const modeFilter = buildModeIntersectionFilter(req.user)
const skills = await skillsCollection const skills = await skillsCollection
.find( .find(
{ $or: [{ owner: req.user.id }, { owner: 'system' }] }, { $and: [{ $or: [{ owner: req.user.id }, { owner: 'system' }] }, modeFilter] },
{ {
projection: { projection: {
name: 1, name: 1,
@ -473,12 +587,64 @@ 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
*
* Body: { files, description?, tags?, localModifiedAt?, force?, replaceAll? }
*
* - `files`: array of { path, content }.
* - `replaceAll` (default false): when updating an existing skill, false = merge by path
* (request patches overwrite; paths not listed are kept). true = full replace of `files`
* (omit remote-only paths useful for folder sync / restore exact snapshot).
*
* Versioning: on each successful update, a new entry is appended to `versions` whose
* `files` field is a snapshot of the **previous** top-level `files` (before this write).
* The document root `files` is always the **latest** snapshot. So the highest version
* number in `versions` is **not** the same as root `files` it is one revision behind.
*/
app.post('/api/skills/:name/publish', async (req, res) => { app.post('/api/skills/:name/publish', async (req, res) => {
authRoutes.verifyToken(req, res, async () => { authRoutes.verifyToken(req, res, async () => {
try { try {
const { files, description, tags, localModifiedAt } = req.body if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewSkillsPage')) {
return res.status(403).json({ success: false, error: '无权发布或修改技能' })
}
const { files, description, tags, localModifiedAt, replaceAll } = req.body
const userId = req.user.id const userId = req.user.id
const userNickname = req.user.nickname || req.user.email 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) { if (!files || !Array.isArray(files) || files.length === 0) {
return res.status(400).json({ success: false, error: 'No files provided' }) return res.status(400).json({ success: false, error: 'No files provided' })
@ -492,7 +658,7 @@ app.post('/api/skills/:name/publish', async (req, res) => {
if (existingSkill) { if (existingSkill) {
const activeLock = getActiveLock(existingSkill) const activeLock = getActiveLock(existingSkill)
if (activeLock && activeLock.userId !== userId) { if (activeLock && !sameUserId(activeLock.userId, userId)) {
return res.status(423).json({ return res.status(423).json({
success: false, success: false,
error: `${activeLock.by} 正在编辑,暂时不能发布`, error: `${activeLock.by} 正在编辑,暂时不能发布`,
@ -520,6 +686,11 @@ app.post('/api/skills/:name/publish', async (req, res) => {
}) })
} }
const useReplaceAll = replaceAll === true
const nextFiles = useReplaceAll
? files
: mergeSkillFilesByPath(existingSkill.files || [], files)
const versionEntry = { const versionEntry = {
version: (existingSkill.versions?.length || 0) + 1, version: (existingSkill.versions?.length || 0) + 1,
description: `Updated by ${userNickname}`, description: `Updated by ${userNickname}`,
@ -531,12 +702,12 @@ app.post('/api/skills/:name/publish', async (req, res) => {
const updateData = { const updateData = {
$set: { $set: {
files, files: nextFiles,
description: skillDescription, description: skillDescription,
updated_at: now, updated_at: now,
updated_by: userId, updated_by: userId,
updated_by_nickname: userNickname, updated_by_nickname: userNickname,
tags: tags || existingSkill.tags || [] tags: normalizedTags.length > 0 ? normalizedTags : (existingSkill.tags || [])
}, },
$push: { versions: versionEntry } $push: { versions: versionEntry }
} }
@ -561,7 +732,7 @@ app.post('/api/skills/:name/publish', async (req, res) => {
files, files,
downloads: 0, downloads: 0,
is_public: true, is_public: true,
tags: tags || [], tags: normalizedTags,
versions: [{ versions: [{
version: 1, version: 1,
description: 'Initial version', description: 'Initial version',
@ -710,15 +881,27 @@ app.get('/api/stats', async (req, res) => {
app.get('/api/agents', async (req, res) => { app.get('/api/agents', async (req, res) => {
try { try {
const { query, offset = 0, limit = 100 } = req.query const { query, offset = 0, limit = 100, modeTag } = req.query
let filter = { is_public: PUBLIC_VISIBILITY_FILTER } const requestedTags = modeTag ? [modeTag] : []
const baseFilter = {
is_public: PUBLIC_VISIBILITY_FILTER,
...buildModeIntersectionFilter(req.user, requestedTags)
}
let filter = baseFilter
if (query && query.trim()) { if (query && query.trim()) {
const q = query.trim() const q = query.trim()
filter.$or = [ filter = {
$and: [
baseFilter,
{
$or: [
{ name: { $regex: q, $options: 'i' } }, { name: { $regex: q, $options: 'i' } },
{ description: { $regex: q, $options: 'i' } } { description: { $regex: q, $options: 'i' } }
] ]
} }
]
}
}
const total = await agentsCollection.countDocuments(filter) const total = await agentsCollection.countDocuments(filter)
const agents = await agentsCollection const agents = await agentsCollection
.find(filter, { projection: { name: 1, description: 1, owner: 1, updated_at: 1, tags: 1 } }) .find(filter, { projection: { name: 1, description: 1, owner: 1, updated_at: 1, tags: 1 } })
@ -739,8 +922,12 @@ app.get('/api/agents', async (req, res) => {
app.get('/api/agents/mine', async (req, res, next) => { app.get('/api/agents/mine', async (req, res, next) => {
authRoutes.verifyToken(req, res, async () => { authRoutes.verifyToken(req, res, async () => {
try { try {
const modeFilter = buildModeIntersectionFilter(req.user)
const agents = await agentsCollection 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 }) .sort({ updated_at: -1 })
.toArray() .toArray()
res.json({ res.json({
@ -753,6 +940,7 @@ app.get('/api/agents/mine', async (req, res, next) => {
owner: a.owner, owner: a.owner,
is_public: a.is_public !== false, is_public: a.is_public !== false,
updated_at: a.updated_at, updated_at: a.updated_at,
tags: a.tags || [],
lock: getActiveLock(a) lock: getActiveLock(a)
})) }))
}) })
@ -765,7 +953,9 @@ app.get('/api/agents/mine', async (req, res, next) => {
app.get('/api/agents/:name', async (req, res) => { app.get('/api/agents/:name', async (req, res) => {
try { try {
const agent = await agentsCollection.findOne({ name: req.params.name, is_public: PUBLIC_VISIBILITY_FILTER }) 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 } }) 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) { } catch (err) {
res.status(500).json({ success: false, error: err.message }) res.status(500).json({ success: false, error: err.message })
@ -775,9 +965,14 @@ app.get('/api/agents/:name', async (req, res) => {
app.post('/api/agents/:name/publish', async (req, res) => { app.post('/api/agents/:name/publish', async (req, res) => {
authRoutes.verifyToken(req, res, async () => { authRoutes.verifyToken(req, res, async () => {
try { try {
const { content, description, localModifiedAt } = req.body if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewAgentsPage')) {
return res.status(403).json({ success: false, error: '无权发布或修改智能体' })
}
const { content, description, tags, localModifiedAt } = req.body
const userId = req.user.id const userId = req.user.id
const userNickname = req.user.nickname || req.user.email 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' }) if (!content) return res.status(400).json({ success: false, error: 'No content provided' })
const agentName = req.params.name.toLowerCase().replace(/[^a-z0-9-]/g, '-') const agentName = req.params.name.toLowerCase().replace(/[^a-z0-9-]/g, '-')
@ -786,7 +981,7 @@ app.post('/api/agents/:name/publish', async (req, res) => {
if (existing) { if (existing) {
const activeLock = getActiveLock(existing) const activeLock = getActiveLock(existing)
if (activeLock && activeLock.userId !== userId) { if (activeLock && !sameUserId(activeLock.userId, userId)) {
return res.status(423).json({ return res.status(423).json({
success: false, success: false,
error: `${activeLock.by} 正在编辑,暂时不能发布`, error: `${activeLock.by} 正在编辑,暂时不能发布`,
@ -808,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 } const versionEntry = { version: (existing.versions?.length || 0) + 1, content: existing.content, created_at: now, created_by: userId, created_by_nickname: userNickname }
await agentsCollection.updateOne( await agentsCollection.updateOne(
{ _id: existing._id }, { _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 }) res.json({ success: true, action: 'updated', name: agentName, version: versionEntry.version })
} else { } else {
@ -819,6 +1024,7 @@ app.post('/api/agents/:name/publish', async (req, res) => {
owner_nickname: userNickname, owner_nickname: userNickname,
content, content,
is_public: true, is_public: true,
tags: normalizedTags,
versions: [{ version: 1, content, created_at: now, created_by: userId, created_by_nickname: userNickname }], versions: [{ version: 1, content, created_at: now, created_by: userId, created_by_nickname: userNickname }],
created_at: now, created_at: now,
updated_at: now, updated_at: now,
@ -836,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) => { app.delete('/api/agents/:name', requireAdmin(async (req, res) => {
try { try {
const agent = await agentsCollection.findOne({ name: req.params.name }) const agent = await agentsCollection.findOne({ name: req.params.name })
@ -850,10 +1087,13 @@ app.delete('/api/agents/:name', requireAdmin(async (req, res) => {
app.post('/api/agents/:name/lock', async (req, res, next) => { app.post('/api/agents/:name/lock', async (req, res, next) => {
authRoutes.verifyToken(req, res, async () => { authRoutes.verifyToken(req, res, async () => {
try { try {
if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewAgentsPage')) {
return res.status(403).json({ success: false, error: '无权编辑智能体' })
}
const agent = await agentsCollection.findOne({ name: req.params.name }) const agent = await agentsCollection.findOne({ name: req.params.name })
if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' }) if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' })
const activeLock = getActiveLock(agent) const activeLock = getActiveLock(agent)
if (activeLock && activeLock.userId !== req.user.id) { if (activeLock && !sameUserId(activeLock.userId, req.user.id)) {
return res.status(423).json({ success: false, error: `${activeLock.by} 正在编辑`, locked_by: activeLock.by }) return res.status(423).json({ success: false, error: `${activeLock.by} 正在编辑`, locked_by: activeLock.by })
} }
await agentsCollection.updateOne({ _id: agent._id }, { $set: { lock: { userId: req.user.id, nickname: req.user.nickname || req.user.email, at: new Date().toISOString() } } }) await agentsCollection.updateOne({ _id: agent._id }, { $set: { lock: { userId: req.user.id, nickname: req.user.nickname || req.user.email, at: new Date().toISOString() } } })
@ -867,11 +1107,14 @@ app.post('/api/agents/:name/lock', async (req, res, next) => {
app.delete('/api/agents/:name/lock', async (req, res, next) => { app.delete('/api/agents/:name/lock', async (req, res, next) => {
authRoutes.verifyToken(req, res, async () => { authRoutes.verifyToken(req, res, async () => {
try { try {
if (req.user.role !== 'admin' && !authRoutes.hasPermission(req.user, 'canViewAgentsPage')) {
return res.status(403).json({ success: false, error: '无权编辑智能体' })
}
const agent = await agentsCollection.findOne({ name: req.params.name }) const agent = await agentsCollection.findOne({ name: req.params.name })
if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' }) if (!agent) return res.status(404).json({ success: false, error: 'Agent not found' })
const activeLock = getActiveLock(agent) const activeLock = getActiveLock(agent)
const isAdmin = req.user.role === 'admin' const isAdmin = req.user.role === 'admin'
if (activeLock && activeLock.userId !== req.user.id && !isAdmin) { if (activeLock && !sameUserId(activeLock.userId, req.user.id) && !isAdmin) {
return res.status(403).json({ success: false, error: '只能由加锁用户或管理员解锁' }) return res.status(403).json({ success: false, error: '只能由加锁用户或管理员解锁' })
} }
await agentsCollection.updateOne({ _id: agent._id }, { $unset: { lock: '' } }) await agentsCollection.updateOne({ _id: agent._id }, { $unset: { lock: '' } })

283
updates/index.html Normal file
View File

@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LikeCowork 安装指南</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
}
.card {
background: white;
border-radius: 20px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
h1 {
text-align: center;
color: #333;
margin-bottom: 10px;
font-size: 32px;
}
.subtitle {
text-align: center;
color: #666;
margin-bottom: 40px;
}
.platform-section {
margin-bottom: 40px;
}
.platform-title {
font-size: 20px;
color: #333;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #667eea;
}
.step {
display: flex;
align-items: flex-start;
margin-bottom: 20px;
}
.step-number {
width: 32px;
height: 32px;
background: #667eea;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
flex-shrink: 0;
margin-right: 15px;
}
.step-content {
flex: 1;
}
.step-title {
font-weight: 600;
color: #333;
margin-bottom: 5px;
}
.step-desc {
color: #666;
font-size: 14px;
}
.download-btn {
display: inline-block;
background: #667eea;
color: white;
padding: 12px 30px;
border-radius: 10px;
text-decoration: none;
font-weight: 600;
margin: 10px 5px 10px 0;
transition: transform 0.2s, box-shadow 0.2s;
}
.download-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
.terminal {
background: #1e1e1e;
color: #d4d4d4;
padding: 15px 20px;
border-radius: 10px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 14px;
margin: 10px 0;
overflow-x: auto;
}
.terminal .cmd { color: #9cdcfe; }
.terminal .path { color: #ce9178; }
.warning {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 10px;
padding: 15px;
margin: 20px 0;
}
.warning-title {
font-weight: 600;
color: #856404;
margin-bottom: 5px;
}
.copy-btn {
background: #28a745;
color: white;
border: none;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
margin-left: 10px;
}
.copy-btn:hover { background: #218838; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<h1>📦 LikeCowork 安装指南</h1>
<p class="subtitle">Mac / Windows 双平台桌面应用</p>
<div class="platform-section">
<h2 class="platform-title">🍎 Mac 安装 <span id="version-mac" style="font-size:0.7em;color:#666;"></span></h2>
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<div class="step-title">下载安装包</div>
<div class="step-desc">根据您的 Mac 芯片选择对应版本:</div>
<div>
<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>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<div class="step-title">解压并修复(重要!)</div>
<div class="step-desc">解压后,如果打开应用提示"已损坏",请运行以下命令:</div>
<div class="terminal">
<span class="cmd">xattr -cr</span> <span class="path">~/Downloads/LikeCowork.app</span>
<button class="copy-btn" onclick="copyCmd('mac')">复制</button>
</div>
<div class="warning">
<div class="warning-title">⚠️ 提示</div>
<div>如果解压后找不到应用,请把压缩包解压到「下载」文件夹,然后再运行上述命令。</div>
</div>
</div>
</div>
<div class="step">
<div class="step-number">3</div>
<div class="step-content">
<div class="step-title">移动到应用程序</div>
<div class="step-desc"><strong>LikeCowork.app</strong> 拖入「应用程序」文件夹即可。</div>
</div>
</div>
</div>
<div class="platform-section">
<h2 class="platform-title">🪟 Windows 安装 <span id="version-win" style="font-size:0.7em;color:#666;"></span></h2>
<div class="step">
<div class="step-number">1</div>
<div class="step-content">
<div class="step-title">下载安装包</div>
<div class="step-desc"></div>
<div>
<a id="win-setup" href="https://oss.xintiao85.com/updates/likecowork-1.2.1-setup.exe" class="download-btn">下载 Windows 安装包</a>
</div>
</div>
</div>
<div class="step">
<div class="step-number">2</div>
<div class="step-content">
<div class="step-title">运行安装程序</div>
<div class="step-desc">双击运行安装包,按提示完成安装即可。安装完成后可选择立即运行。</div>
</div>
</div>
</div>
<div class="platform-section">
<h2 class="platform-title">❓ 常见问题</h2>
<div class="step">
<div class="step-content">
<div class="step-title">如何判断我的 Mac 是 Intel 还是 Apple Silicon</div>
<div class="step-desc">点击 Apple 菜单 → 关于本机 → 概览,如果显示「芯片」就是 Apple Silicon「处理器」就是 Intel。</div>
</div>
</div>
<div class="step">
<div class="step-content">
<div class="step-title">Mac 提示"已损坏"无法打开?</div>
<div class="step-desc">这是 macOS 安全限制。请在终端运行:<code style="background:#f5f5f5;padding:2px 6px;border-radius:4px;">xattr -cr ~/Downloads/LikeCowork.app</code></div>
</div>
</div>
</div>
</div>
</div>
<script>
const OSS_UPDATES_BASE = 'https://oss.xintiao85.com/updates';
// 动态获取最新版本
async function fetchLatestVersion() {
try {
// 固定从对象存储读取最新元数据
const [macRes, winRes] = await Promise.all([
fetch(`${OSS_UPDATES_BASE}/latest-mac.yml`),
fetch(`${OSS_UPDATES_BASE}/latest.yml`)
]);
if (!macRes.ok) {
console.warn('latest-mac.yml 不可用,保留页面默认下载链接', macRes.status);
} else {
const macText = await macRes.text();
const macMatch = macText.match(/version:\s*([0-9]+\.[0-9]+\.[0-9]+)/);
if (macMatch) {
const version = macMatch[1];
document.getElementById('version-mac').textContent = `(v${version})`;
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`;
}
}
if (!winRes.ok) {
console.warn('latest.yml 不可用,保留页面默认下载链接', winRes.status);
} else {
const winText = await winRes.text();
const winMatch = winText.match(/version:\s*([0-9]+\.[0-9]+\.[0-9]+)/);
if (winMatch) {
const version = winMatch[1];
document.getElementById('version-win').textContent = `(v${version})`;
document.getElementById('win-setup').href = `${OSS_UPDATES_BASE}/likecowork-${version}-setup.exe`;
}
}
} catch (e) {
console.error('获取版本失败,已使用页面默认下载链接:', e);
}
}
fetchLatestVersion();
function copyCmd(type) {
const cmds = {
'mac': 'xattr -cr ~/Downloads/LikeCowork.app'
};
const text = cmds[type];
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
alert('已复制到剪贴板!');
});
} else {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
alert('已复制到剪贴板!');
}
}
</script>
</body>
</html>

11
updates/latest-mac.yml Normal file
View File

@ -0,0 +1,11 @@
version: 1.2.1
files:
- url: likecowork-1.2.1-x64-mac.zip
sha512: QIOFv41c9MYOPPKaxz4QunYHZJoobVSXhQYXQFgFJUm89Hf3AEC8PtHx3N8e7A99Nun7UuYwO16EaS9YmMJu7w==
size: 978479730
- url: likecowork-1.2.1-arm64-mac.zip
sha512: L6CFKjmxD0Aep4gXkX2ojPuhPnwphHwoEVZXcI45WXmMzQRZfSG3bXY4o+PdZ+nfE7CFYkYbGl9g9yTbc/fPKQ==
size: 973708487
path: likecowork-1.2.1-x64-mac.zip
sha512: QIOFv41c9MYOPPKaxz4QunYHZJoobVSXhQYXQFgFJUm89Hf3AEC8PtHx3N8e7A99Nun7UuYwO16EaS9YmMJu7w==
releaseDate: '2026-03-27T08:06:20.025Z'

8
updates/latest.yml Normal file
View File

@ -0,0 +1,8 @@
version: 1.2.1
files:
- url: likecowork-1.2.1-setup.exe
sha512: c3DLm7mfzV5e94GRDSkB0RWkqvSOJO04TVbMBR3FIoVsiRCTtGn/raRXYfs+xTS9mZ5JlpkYTdM+gtS3aQ3UhQ==
size: 938877301
path: likecowork-1.2.1-setup.exe
sha512: c3DLm7mfzV5e94GRDSkB0RWkqvSOJO04TVbMBR3FIoVsiRCTtGn/raRXYfs+xTS9mZ5JlpkYTdM+gtS3aQ3UhQ==
releaseDate: '2026-03-27T07:51:13.507Z'