feat: multi-tenant workspace isolation, RBAC, sidebar nav, billing, and Android enhancements

- Backend: workspace_id isolation for 14 model tables + safe migration/backfill
- Backend: RBAC system with 4 roles and 23 permissions, seeded on startup
- Backend: workspace admin endpoints (list/manage all workspaces)
- Backend: admin user management API (CRUD, reset password)
- Backend: billing API with subscription plans, usage tracking, rate limiting
- Backend: fix system_logs.py UNION query and wrong column references
- Backend: WebSocket JWT auth and workspace enforcement
- Frontend: sidebar navigation replacing top dropdown menu
- Frontend: user management page (Users.vue) for admins
- Frontend: enhanced Workspaces.vue with admin table view
- Frontend: workspace RBAC computed properties in user store
- Android: agent marketplace, billing/subscription UI, onboarding wizard
- Android: phone login, analytics tracker, crash handler, network diagnostics
- Android: splash screen, encrypted token storage, app update enhancements
- Docs: multi-tenant RBAC guide with 8 sections and role-permission matrix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 01:00:22 +08:00
parent 51eafb23f3
commit 876789fac1
93 changed files with 8803 additions and 669 deletions

View File

@@ -1,96 +1,125 @@
<template>
<div class="main-layout">
<el-container>
<el-header>
<div class="header-content">
<div class="header-left">
<h1>天工智能体平台</h1>
</div>
<div class="header-center">
<GlobalSearch />
</div>
<div class="header-actions">
<NotificationCenter ref="notifyCenterRef" />
<el-button
text
class="theme-toggle-btn"
:aria-label="themeStore.isDark ? '切换到亮色模式' : '切换到暗色模式'"
@click="themeStore.toggle()"
>
<el-icon :size="20">
<Sunny v-if="themeStore.isDark" />
<Moon v-else />
</el-icon>
</el-button>
<el-select
v-if="userStore.workspaces.length > 1"
:model-value="userStore.currentWorkspaceId"
class="workspace-selector"
placeholder="选择工作区"
@change="handleSwitchWorkspace"
>
<el-option
v-for="ws in userStore.workspaces"
:key="ws.id"
:label="ws.name"
:value="ws.id"
>
<span style="float: left">{{ ws.name }}</span>
<span style="float: right; color: #8492a6; font-size: 13px; margin-left: 10px">{{ ws.role === 'admin' ? '管理员' : '成员' }}</span>
</el-option>
</el-select>
<span v-if="userStore.user">欢迎{{ userStore.user.username }}</span>
<el-button @click="handleLogout">退出</el-button>
</div>
</div>
</el-header>
<el-main>
<!-- 分组下拉导航菜单 -->
<nav class="menu-bar">
<el-dropdown
v-for="group in menuGroups"
:key="group.name"
trigger="hover"
class="menu-dropdown"
popper-class="menu-dropdown-popper"
<!-- 顶栏 -->
<el-header height="52px">
<div class="header-left">
<el-button
class="collapse-btn"
text
@click="sidebarCollapsed = !sidebarCollapsed"
>
<el-button type="default" text size="large" class="menu-group-btn">
<el-icon><component :is="group.icon" /></el-icon>
<span>{{ group.label }}</span>
<el-icon :size="20">
<Fold v-if="!sidebarCollapsed" />
<Expand v-else />
</el-icon>
</el-button>
<span class="brand-name">天工智能体平台</span>
</div>
<div class="header-center">
<GlobalSearch />
</div>
<div class="header-right">
<NotificationCenter ref="notifyCenterRef" />
<el-button
text
class="theme-toggle-btn"
:aria-label="themeStore.isDark ? '切换到亮色模式' : '切换到暗色模式'"
@click="themeStore.toggle()"
>
<el-icon :size="18">
<Sunny v-if="themeStore.isDark" />
<Moon v-else />
</el-icon>
</el-button>
<el-select
v-if="userStore.workspaces.length > 1"
:model-value="userStore.currentWorkspaceId"
class="workspace-selector"
placeholder="选择工作区"
size="small"
@change="handleSwitchWorkspace"
>
<el-option
v-for="ws in userStore.workspaces"
:key="ws.id"
:label="ws.name"
:value="ws.id"
>
<span style="float: left">{{ ws.name }}</span>
<span style="float: right; color: #8492a6; font-size: 12px; margin-left: 8px">
{{ ws.role === 'admin' ? '管理员' : '成员' }}
</span>
</el-option>
</el-select>
<el-dropdown trigger="click">
<span class="user-info">
<el-icon><UserFilled /></el-icon>
<span class="username">{{ userStore.user?.username }}</span>
<el-icon class="arrow-icon"><ArrowDown /></el-icon>
</el-button>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in group.children"
:key="item.path"
:class="{ 'is-active': isActiveMenu(item) }"
@click="navigateTo(item)"
>
<div class="dropdown-item-content">
<el-icon><component :is="item.icon" /></el-icon>
<div class="item-text">
<span class="item-label">{{ item.label }}</span>
<span class="item-desc">{{ item.description }}</span>
</div>
</div>
<el-dropdown-item @click="router.push('/profile')">
<el-icon><User /></el-icon>个人设置
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</nav>
<!-- 面包屑 -->
<BreadcrumbNav />
<!-- 页面内容 -->
<div class="page-content">
<slot />
</div>
</el-header>
<!-- 反馈浮动按钮 -->
<FeedbackWidget />
</el-main>
<el-container class="body-container">
<!-- 侧边栏 -->
<el-aside :width="sidebarCollapsed ? '64px' : '220px'" class="sidebar-aside">
<el-menu
:collapse="sidebarCollapsed"
:default-active="activeMenu"
:default-openeds="defaultOpeneds"
router
class="sidebar-menu"
:collapse-transition="false"
>
<!-- 主控台独立入口 -->
<el-menu-item index="/console">
<el-icon><Grid /></el-icon>
<span>主控台</span>
</el-menu-item>
<!-- 分组菜单 -->
<el-sub-menu
v-for="group in menuGroups"
:key="group.name"
:index="group.name"
>
<template #title>
<el-icon><component :is="group.icon" /></el-icon>
<span>{{ group.label }}</span>
</template>
<el-menu-item
v-for="item in group.children"
:key="item.index"
:index="item.path"
>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
<!-- 内容区 -->
<el-main>
<BreadcrumbNav />
<div class="page-content">
<slot />
</div>
<FeedbackWidget />
</el-main>
</el-container>
</el-container>
</div>
</template>
@@ -104,7 +133,7 @@ import {
Grid, Document, User, ChatLineSquare, Share, UserFilled,
Clock, Monitor, List, Connection, Setting, Star, Shop,
Tools, Bell, Tickets, OfficeBuilding, Lock, ArrowDown, DataAnalysis,
Sunny, Moon, Iphone
Sunny, Moon, Iphone, Fold, Expand, SwitchButton
} from '@element-plus/icons-vue'
import GlobalSearch from '@/components/GlobalSearch.vue'
import BreadcrumbNav from '@/components/BreadcrumbNav.vue'
@@ -117,7 +146,9 @@ const userStore = useUserStore()
const themeStore = useThemeStore()
const notifyCenterRef = ref<InstanceType<typeof NotificationCenter> | null>(null)
// ──────────── 菜单分组定义 ────────────
const sidebarCollapsed = ref(false)
// ──────────── 菜单定义 ────────────
interface MenuItem {
index: string
label: string
@@ -142,7 +173,6 @@ const menuGroups = computed<MenuGroup[]>(() => {
label: '核心功能',
icon: Grid,
children: [
{ index: 'console', label: '主控台', description: '平台概览仪表盘', path: '/console', icon: Grid },
{ index: 'workflows', label: '工作流管理', description: '创建和管理工作流', path: '/', pathPattern: '/workflow', icon: Document },
{ index: 'agents', label: 'Agent管理', description: '管理所有智能体', path: '/agents', pathPattern: '/agents', icon: User },
],
@@ -186,37 +216,62 @@ const menuGroups = computed<MenuGroup[]>(() => {
icon: Setting,
children: [
{ index: 'data-sources', label: '数据源管理', description: '配置数据源', path: '/data-sources', icon: Connection },
{ index: 'model-configs', label: '模型配置管理', description: '管理LLM模型', path: '/model-configs', icon: Setting },
{ index: 'model-configs', label: '模型配置', description: '管理LLM模型', path: '/model-configs', icon: Setting },
{ index: 'workspaces', label: '工作区', description: '管理多工作区', path: '/workspaces', pathPattern: '/workspace', icon: OfficeBuilding },
{ index: 'permissions', label: '权限管理', description: '用户权限管理', path: '/permissions', icon: Lock, adminOnly: true },
{ index: 'app-versions', label: 'App版本管理', description: '发布App新版本', path: '/app-versions', icon: Iphone, adminOnly: true },
{ index: 'users', label: '用户管理', description: '用户列表与权限管理', path: '/users', icon: User, adminOnly: true },
],
},
]
// Filter admin-only items
// 过滤管理员专属项
return groups.map(g => ({
...g,
children: g.children.filter(item => !item.adminOnly || userStore.user?.role === 'admin'),
})).filter(g => g.children.length > 0)
})
// ──────────── 菜单激活状态 ────────────
function isActiveMenu(item: MenuItem): boolean {
if (item.pathPattern) {
return route.path.startsWith(item.pathPattern)
}
if (route.path === item.path) return true
// 根路径特殊处理
if (item.index === 'workflows' && (route.path === '/' || route.path.startsWith('/workflow/'))) return true
return false
}
// ──────────── 活动菜单计算 ────────────
const activeMenu = computed(() => {
const path = route.path
// ──────────── 路由导航 ────────────
function navigateTo(item: MenuItem) {
if (item.path === route.path) return // already there
router.push(item.path)
}
// 遍历所有菜单项找匹配
for (const group of menuGroups.value) {
for (const item of group.children) {
if (item.pathPattern) {
if (path.startsWith(item.pathPattern)) return item.path
}
if (path === item.path) return item.path
// 根路径匹配工作流
if (item.index === 'workflows' && (path === '/' || path.startsWith('/workflow/'))) {
return item.path
}
}
}
// 主控台
if (path === '/console') return '/console'
// 默认返回当前路径
return path
})
// ──────────── 默认展开的菜单组 ────────────
const defaultOpeneds = computed(() => {
// 找到当前活动菜单所属的 group
for (const group of menuGroups.value) {
for (const item of group.children) {
if (activeMenu.value === item.path) return [group.name]
// 路径匹配
if (item.pathPattern && route.path.startsWith(item.pathPattern)) return [group.name]
if (item.index === 'workflows' && (route.path === '/' || route.path.startsWith('/workflow/'))) {
return [group.name]
}
}
}
return ['core']
})
// ──────────── 工作区切换 ────────────
const handleSwitchWorkspace = async (workspaceId: string) => {
@@ -241,21 +296,45 @@ const handleLogout = () => {
height: 100vh;
}
.header-content {
/* ══════════ Header ══════════ */
:deep(.el-header) {
--el-header-padding: 0;
height: 52px;
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
padding: 0 20px;
background: #409eff;
color: white;
gap: 24px;
background: #fff;
border-bottom: 1px solid var(--el-border-color-lighter);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
z-index: 100;
position: relative;
}
.header-left h1 {
margin: 0;
font-size: 18px;
font-weight: 500;
html.dark :deep(.el-header) {
background: #1d1e1f;
border-color: var(--el-border-color-darker);
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
padding-left: 12px;
flex-shrink: 0;
}
.collapse-btn {
color: var(--el-text-color-regular);
padding: 6px;
}
.collapse-btn:hover {
background: var(--el-fill-color-light);
}
.brand-name {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
}
@@ -263,122 +342,103 @@ const handleLogout = () => {
flex: 1;
display: flex;
justify-content: center;
max-width: 480px;
max-width: 420px;
margin: 0 auto;
}
.header-actions {
.header-right {
display: flex;
align-items: center;
gap: 15px;
white-space: nowrap;
gap: 10px;
padding-right: 16px;
flex-shrink: 0;
}
.workspace-selector {
width: 180px;
width: 170px;
}
.theme-toggle-btn {
color: white;
color: var(--el-text-color-regular);
padding: 4px;
}
.theme-toggle-btn:hover {
background: rgba(255, 255, 255, 0.15);
background: var(--el-fill-color-light);
}
/* ───── 菜单栏 ───── */
.menu-bar {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 0 2px;
margin-bottom: 2px;
border-bottom: 1px solid var(--el-border-color-lighter);
overflow-x: auto;
flex-shrink: 0;
}
.menu-dropdown {
flex-shrink: 0;
}
.menu-group-btn {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-regular);
padding: 8px 14px;
border-radius: 6px;
transition: all 0.2s;
.user-info {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
color: var(--el-text-color-regular);
font-size: 14px;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.2s;
}
.menu-group-btn:hover {
.user-info:hover {
background: var(--el-fill-color-light);
color: var(--el-color-primary);
}
.username {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arrow-icon {
font-size: 12px;
transition: transform 0.2s;
}
/* ───── 下拉项 ───── */
:deep(.menu-dropdown-popper) {
min-width: 240px;
/* ══════════ Body ══════════ */
.body-container {
height: calc(100vh - 52px);
}
:deep(.menu-dropdown-popper .el-dropdown-menu__item) {
padding: 8px 12px;
/* ══════════ Sidebar ══════════ */
.sidebar-aside {
transition: width 0.2s ease;
overflow: hidden;
border-right: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
}
.dropdown-item-content {
display: flex;
align-items: flex-start;
gap: 10px;
width: 100%;
}
.dropdown-item-content > .el-icon {
margin-top: 2px;
font-size: 18px;
color: var(--el-color-primary);
flex-shrink: 0;
}
.item-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.item-label {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.item-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.3;
}
:deep(.el-dropdown-menu__item.is-active) {
background: var(--el-color-primary-light-9);
}
:deep(.el-dropdown-menu__item.is-active .item-label) {
color: var(--el-color-primary);
}
/* ───── 适配 ───── */
:deep(.el-main) {
display: flex;
flex-direction: column;
padding: 12px 20px 20px;
.sidebar-menu {
border-right: none;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-menu:not(.el-menu--collapse) {
width: 219px;
}
/* 子菜单缩进微调 */
.sidebar-menu :deep(.el-sub-menu .el-menu-item) {
padding-left: 56px !important;
}
.sidebar-menu.el-menu--collapse :deep(.el-sub-menu .el-menu-item) {
padding-left: 24px !important;
}
/* 子菜单标题图标间距 */
.sidebar-menu :deep(.el-sub-menu__title .el-icon) {
margin-right: 8px;
}
/* ══════════ Main Content ══════════ */
:deep(.el-main) {
--el-main-padding: 12px 20px 20px;
display: flex;
flex-direction: column;
overflow-y: auto;
background: var(--el-bg-color-page);
}
.page-content {
@@ -388,18 +448,30 @@ const handleLogout = () => {
min-height: 0;
}
/* 搜索框暗色适配 */
/* ══════════ Dark mode adjustments ══════════ */
html.dark .sidebar-aside {
background: #1d1e1f;
border-color: var(--el-border-color-darker);
}
html.dark :deep(.el-main) {
background: #141414;
}
/* ══════════ Header search adapt ══════════ */
.header-center :deep(.search-trigger) {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.85);
background: var(--el-fill-color-light);
border-color: var(--el-border-color-light);
color: var(--el-text-color-regular);
}
.header-center :deep(.search-trigger):hover {
background: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 255, 255, 0.4);
background: var(--el-fill-color);
border-color: var(--el-border-color);
}
.header-center :deep(.search-trigger kbd) {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.6);
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -213,6 +213,12 @@ const router = createRouter({
name: 'app-versions',
component: () => import('@/views/AppVersionManage.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: '/users',
name: 'users',
component: () => import('@/views/Users.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]
})

View File

@@ -18,6 +18,19 @@ export const useUserStore = defineStore('user', () => {
return workspaces.value.find(w => w.id === currentWorkspaceId.value) || null
})
// 当前工作区角色
const currentWorkspaceRole = computed(() => {
if (!currentWorkspaceId.value) return null
const ws = workspaces.value.find(w => w.id === currentWorkspaceId.value)
return ws?.role || null
})
// 是否为工作区管理员平台管理员或工作区admin
const isWorkspaceAdmin = computed(() => {
if (user.value?.role === 'admin') return true
return currentWorkspaceRole.value === 'admin'
})
// 设置token
const setToken = (newToken: string) => {
token.value = newToken
@@ -136,6 +149,8 @@ export const useUserStore = defineStore('user', () => {
workspaces,
currentWorkspaceId,
currentWorkspace,
currentWorkspaceRole,
isWorkspaceAdmin,
login,
register,
logout,

View File

@@ -0,0 +1,408 @@
<template>
<MainLayout>
<div class="users-page">
<el-card>
<template #header>
<div class="card-header">
<h2>用户管理</h2>
<el-button type="primary" @click="openCreateDialog">
<el-icon><Plus /></el-icon>
创建用户
</el-button>
</div>
</template>
<!-- 搜索和过滤 -->
<div class="toolbar">
<el-input
v-model="search"
placeholder="搜索用户名或邮箱"
clearable
style="width: 260px"
@clear="fetchUsers"
@keyup.enter="fetchUsers"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select v-model="statusFilter" placeholder="状态" clearable style="width: 140px" @change="fetchUsers">
<el-option label="全部" value="" />
<el-option label="正常" value="active" />
<el-option label="已禁用" value="disabled" />
<el-option label="已删除" value="deleted" />
</el-select>
<el-select v-model="roleFilter" placeholder="角色" clearable style="width: 140px" @change="fetchUsers">
<el-option label="全部" value="" />
<el-option label="管理员" value="admin" />
<el-option label="普通用户" value="user" />
</el-select>
<el-button type="primary" @click="fetchUsers">查询</el-button>
</div>
<!-- 用户表格 -->
<el-table :data="users" stripe v-loading="loading" style="margin-top: 16px">
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="email" label="邮箱" min-width="180" />
<el-table-column prop="role" label="角色" width="100">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : 'primary'" size="small">
{{ row.role === 'admin' ? '管理员' : '用户' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="phone" label="手机号" width="130">
<template #default="{ row }">
{{ row.phone || '-' }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag
:type="statusTagType(row.status)"
size="small"
>
{{ statusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="subscription_tier" label="订阅" width="100">
<template #default="{ row }">
<el-tag :type="subTagType(row.subscription_tier)" size="small">
{{ subLabel(row.subscription_tier) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="workspace_count" label="工作区" width="80" align="center" />
<el-table-column prop="daily_usage_count" label="今日用量" width="90" align="center" />
<el-table-column prop="created_at" label="创建时间" width="170">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="warning" plain @click="openResetPwdDialog(row)">重置密码</el-button>
<el-button
v-if="row.status !== 'deleted'"
size="small"
type="danger"
plain
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
style="margin-top: 16px; justify-content: flex-end"
@current-change="fetchUsers"
@size-change="fetchUsers"
/>
</el-card>
<!-- 创建/编辑用户对话框 -->
<el-dialog
v-model="dialogVisible"
:title="editingUser ? '编辑用户' : '创建用户'"
width="520px"
@closed="resetForm"
>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" :disabled="!!editingUser" placeholder="2-50个字符" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="user@example.com" />
</el-form-item>
<el-form-item v-if="!editingUser" label="密码" prop="password">
<el-input v-model="form.password" type="password" show-password placeholder="至少6位" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.role" style="width: 100%">
<el-option label="普通用户" value="user" />
<el-option label="管理员" value="admin" />
</el-select>
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="form.phone" placeholder="选填" />
</el-form-item>
<el-form-item label="订阅等级">
<el-select v-model="form.subscription_tier" style="width: 100%">
<el-option label="免费版" value="free" />
<el-option label="专业版" value="pro" />
<el-option label="企业版" value="enterprise" />
</el-select>
</el-form-item>
<el-form-item v-if="editingUser" label="状态">
<el-select v-model="form.status" style="width: 100%">
<el-option label="正常" value="active" />
<el-option label="已禁用" value="disabled" />
<el-option label="已删除" value="deleted" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
{{ editingUser ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
<!-- 重置密码对话框 -->
<el-dialog
v-model="pwdDialogVisible"
title="重置密码"
width="400px"
@closed="pwdForm.new_password = ''"
>
<p style="margin-bottom: 12px; color: #666">
正在重置用户 <strong>{{ pwdTarget?.username }}</strong> 的密码
</p>
<el-form label-width="100px">
<el-form-item label="新密码">
<el-input v-model="pwdForm.new_password" type="password" show-password placeholder="至少6位" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pwdDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleResetPwd" :loading="pwdSubmitting">
确认重置
</el-button>
</template>
</el-dialog>
</div>
</MainLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import MainLayout from '@/components/MainLayout.vue'
import api from '@/api'
const loading = ref(false)
const users = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const search = ref('')
const statusFilter = ref('')
const roleFilter = ref('')
// ── Dialog state ──
const dialogVisible = ref(false)
const editingUser = ref<any>(null)
const submitting = ref(false)
const formRef = ref()
const form = reactive({
username: '',
email: '',
password: '',
role: 'user',
phone: '',
status: 'active',
subscription_tier: 'free',
})
const formRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 50, message: '用户名长度 2-50', trigger: 'blur' },
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
}
// ── Reset password dialog ──
const pwdDialogVisible = ref(false)
const pwdTarget = ref<any>(null)
const pwdSubmitting = ref(false)
const pwdForm = reactive({ new_password: '' })
// ── API calls ──
async function fetchUsers() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value }
if (search.value) params.search = search.value
if (statusFilter.value) params.status = statusFilter.value
if (roleFilter.value) params.role = roleFilter.value
const res = await api.get('/api/v1/admin/users', { params })
users.value = res.data.items
total.value = res.data.total
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '获取用户列表失败')
} finally {
loading.value = false
}
}
function openCreateDialog() {
editingUser.value = null
resetForm()
dialogVisible.value = true
}
function openEditDialog(user: any) {
editingUser.value = user
form.username = user.username
form.email = user.email
form.role = user.role
form.phone = user.phone || ''
form.status = user.status
form.subscription_tier = user.subscription_tier
form.password = ''
dialogVisible.value = true
}
function resetForm() {
form.username = ''
form.email = ''
form.password = ''
form.role = 'user'
form.phone = ''
form.status = 'active'
form.subscription_tier = 'free'
formRef.value?.resetFields()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
if (editingUser.value) {
await api.put(`/api/v1/admin/users/${editingUser.value.id}`, {
username: form.username,
email: form.email,
role: form.role,
phone: form.phone || null,
status: form.status,
subscription_tier: form.subscription_tier,
})
ElMessage.success('用户信息已更新')
} else {
await api.post('/api/v1/admin/users', {
username: form.username,
email: form.email,
password: form.password,
role: form.role,
phone: form.phone || null,
status: form.status,
})
ElMessage.success('用户已创建')
}
dialogVisible.value = false
fetchUsers()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '操作失败')
} finally {
submitting.value = false
}
}
async function handleDelete(user: any) {
try {
await ElMessageBox.confirm(
`确定要删除用户 "${user.username}" 吗?此操作将软删除用户(标记为 deleted`,
'确认删除',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' },
)
} catch {
return
}
try {
await api.delete(`/api/v1/admin/users/${user.id}`)
ElMessage.success('用户已删除')
fetchUsers()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '删除失败')
}
}
function openResetPwdDialog(user: any) {
pwdTarget.value = user
pwdForm.new_password = ''
pwdDialogVisible.value = true
}
async function handleResetPwd() {
if (!pwdForm.new_password || pwdForm.new_password.length < 6) {
ElMessage.warning('密码至少6位')
return
}
pwdSubmitting.value = true
try {
await api.post(`/api/v1/admin/users/${pwdTarget.value.id}/reset-password`, {
new_password: pwdForm.new_password,
})
ElMessage.success('密码已重置')
pwdDialogVisible.value = false
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '重置失败')
} finally {
pwdSubmitting.value = false
}
}
// ── Helpers ──
function statusTagType(s: string) {
return s === 'active' ? 'success' : s === 'disabled' ? 'warning' : 'info'
}
function statusLabel(s: string) {
return s === 'active' ? '正常' : s === 'disabled' ? '已禁用' : '已删除'
}
function subTagType(t: string) {
return t === 'enterprise' ? 'danger' : t === 'pro' ? 'warning' : 'info'
}
function subLabel(t: string) {
return t === 'enterprise' ? '企业版' : t === 'pro' ? '专业版' : '免费版'
}
function formatDate(d: string | null) {
if (!d) return '-'
return new Date(d).toLocaleString('zh-CN')
}
onMounted(fetchUsers)
</script>
<style scoped>
.users-page {
max-width: 1400px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h2 {
margin: 0;
font-size: 18px;
}
.toolbar {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
</style>

View File

@@ -8,7 +8,60 @@
</el-button>
</div>
<!-- 工作区 -->
<!-- 平台管理员所有工作区表 -->
<div v-if="isPlatformAdmin" class="admin-section">
<h3>所有工作区平台管理员视图</h3>
<div class="admin-toolbar">
<el-input v-model="adminSearch" placeholder="搜索工作区名称" clearable style="width: 240px" @change="loadAdminWorkspaces" />
<el-select v-model="adminStatusFilter" placeholder="状态筛选" clearable style="width: 140px; margin-left: 12px" @change="loadAdminWorkspaces">
<el-option label="启用" value="active" />
<el-option label="停用" value="disabled" />
<el-option label="已删除" value="deleted" />
</el-select>
</div>
<el-table :data="adminWorkspaces" border stripe v-loading="adminLoading" style="margin-top: 12px">
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="owner_name" label="所有者" width="120" />
<el-table-column prop="member_count" label="成员数" width="80" />
<el-table-column prop="max_members" label="上限" width="70" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : row.status === 'deleted' ? 'danger' : 'warning'" size="small">
{{ row.status === 'active' ? '启用' : row.status === 'deleted' ? '已删除' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_default" label="默认" width="70">
<template #default="{ row }">
<el-tag v-if="row.is_default" type="success" size="small"></el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ row.created_at ? new Date(row.created_at).toLocaleDateString() : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="goWorkspace(row.id)">进入</el-button>
<el-button size="small" type="primary" @click="openAdminManage(row)">管理</el-button>
</template>
</el-table-column>
</el-table>
<div class="admin-pagination" style="margin-top: 16px; text-align: right">
<el-pagination
v-model:current-page="adminPage"
:page-size="adminPageSize"
:total="adminTotal"
layout="total, prev, pager, next"
@current-change="loadAdminWorkspaces"
/>
</div>
</div>
<!-- 我的工作区 -->
<h3 v-if="isPlatformAdmin" style="margin-top: 32px">我的工作区</h3>
<el-row :gutter="20">
<el-col v-for="ws in workspaces" :key="ws.id" :span="8">
<el-card class="workspace-card" shadow="hover">
@@ -55,21 +108,45 @@
<el-button type="primary" :loading="creating" @click="handleCreate">确定</el-button>
</template>
</el-dialog>
<!-- 管理员编辑对话框 -->
<el-dialog v-model="showAdminEditDialog" title="管理工作区" width="500px">
<el-form v-if="adminEditWs" label-width="100px">
<el-form-item label="名称">
<el-input v-model="adminEditWs.name" maxlength="100" />
</el-form-item>
<el-form-item label="所有者">{{ adminEditWs.owner_name }}</el-form-item>
<el-form-item label="成员数">{{ adminEditWs.member_count }} / {{ adminEditWs.max_members }}</el-form-item>
<el-form-item label="状态">
<el-select v-model="adminEditWs.status">
<el-option label="启用" value="active" />
<el-option label="停用" value="disabled" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAdminEditDialog = false">取消</el-button>
<el-button type="danger" @click="handleAdminDelete">删除工作区</el-button>
<el-button type="primary" @click="handleAdminSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { Plus } from '@element-plus/icons-vue'
import api from '@/api'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { Workspace } from '@/types'
const router = useRouter()
const userStore = useUserStore()
const isPlatformAdmin = computed(() => userStore.user?.role === 'admin')
const workspaces = ref<Workspace[]>([])
const showCreateDialog = ref(false)
const creating = ref(false)
@@ -79,6 +156,19 @@ const createForm = ref({
max_members: 50,
})
// 管理员视图
const adminWorkspaces = ref<any[]>([])
const adminLoading = ref(false)
const adminSearch = ref('')
const adminStatusFilter = ref('')
const adminPage = ref(1)
const adminPageSize = ref(20)
const adminTotal = ref(0)
// 管理员编辑
const showAdminEditDialog = ref(false)
const adminEditWs = ref<any>(null)
const loadWorkspaces = async () => {
try {
const res = await api.get('/api/v1/workspaces')
@@ -88,6 +178,22 @@ const loadWorkspaces = async () => {
}
}
const loadAdminWorkspaces = async () => {
adminLoading.value = true
try {
const params: any = { page: adminPage.value, page_size: adminPageSize.value }
if (adminSearch.value) params.search = adminSearch.value
if (adminStatusFilter.value) params.status = adminStatusFilter.value
const res = await api.get('/api/v1/admin/workspaces', { params })
adminWorkspaces.value = res.data.items
adminTotal.value = res.data.total
} catch (e: any) {
ElMessage.error('加载所有工作区失败')
} finally {
adminLoading.value = false
}
}
const handleCreate = async () => {
if (!createForm.value.name.trim()) {
ElMessage.warning('请输入名称')
@@ -103,8 +209,9 @@ const handleCreate = async () => {
ElMessage.success('工作区已创建')
showCreateDialog.value = false
createForm.value = { name: '', description: '', max_members: 50 }
await userStore.fetchUser() // 刷新用户信息以更新工作区列表
await userStore.fetchUser()
await loadWorkspaces()
if (isPlatformAdmin.value) await loadAdminWorkspaces()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '创建失败')
} finally {
@@ -125,8 +232,51 @@ const openManage = (id: string) => {
router.push(`/workspace?id=${id}`)
}
const openAdminManage = async (ws: any) => {
try {
const res = await api.get(`/api/v1/admin/workspaces/${ws.id}`)
adminEditWs.value = res.data
showAdminEditDialog.value = true
} catch (e: any) {
ElMessage.error('加载工作区详情失败')
}
}
const handleAdminSave = async () => {
if (!adminEditWs.value) return
try {
await api.put(`/api/v1/admin/workspaces/${adminEditWs.value.id}`, {
name: adminEditWs.value.name,
status: adminEditWs.value.status,
})
ElMessage.success('已保存')
showAdminEditDialog.value = false
await loadAdminWorkspaces()
} catch (e: any) {
ElMessage.error(e.response?.data?.detail || '保存失败')
}
}
const handleAdminDelete = async () => {
if (!adminEditWs.value) return
try {
await ElMessageBox.confirm(`确认删除工作区 "${adminEditWs.value.name}"`, '危险操作', { type: 'error', confirmButtonText: '确认删除' })
await api.delete(`/api/v1/admin/workspaces/${adminEditWs.value.id}`)
ElMessage.success('工作区已删除')
showAdminEditDialog.value = false
await loadAdminWorkspaces()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.response?.data?.detail || '删除失败')
}
}
}
onMounted(() => {
loadWorkspaces()
if (isPlatformAdmin.value) {
loadAdminWorkspaces()
}
})
</script>
@@ -146,6 +296,19 @@ onMounted(() => {
margin: 0;
}
.admin-section {
margin-bottom: 8px;
}
.admin-section h3 {
margin: 0 0 8px 0;
}
.admin-toolbar {
display: flex;
align-items: center;
}
.workspace-card {
margin-bottom: 20px;
cursor: pointer;