feat: ship enterprise control and desktop governance
This commit is contained in:
12
apps/boss-admin-web/index.html
Normal file
12
apps/boss-admin-web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Boss 企业后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1762
apps/boss-admin-web/package-lock.json
generated
Normal file
1762
apps/boss-admin-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
apps/boss-admin-web/package.json
Normal file
23
apps/boss-admin-web/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@boss/admin-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5174",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 4174",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
732
apps/boss-admin-web/src/App.vue
Normal file
732
apps/boss-admin-web/src/App.vue
Normal file
@@ -0,0 +1,732 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { message } from "ant-design-vue";
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
AuditOutlined,
|
||||
ClusterOutlined,
|
||||
DashboardOutlined,
|
||||
SafetyOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
ToolOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import {
|
||||
fetchBossAdminBackoffice,
|
||||
postAdminAccess,
|
||||
postRiskAction,
|
||||
postSkillLifecycleRequest,
|
||||
type BossAdminBackofficePayload,
|
||||
} from "./api/bossAdmin";
|
||||
|
||||
type AdminRecord = Record<string, unknown>;
|
||||
|
||||
const loading = ref(true);
|
||||
const mutating = ref(false);
|
||||
const error = ref("");
|
||||
const activeKey = ref("workbench");
|
||||
const payload = ref<BossAdminBackofficePayload | null>(null);
|
||||
|
||||
const companyForm = reactive({
|
||||
companyId: "",
|
||||
name: "",
|
||||
ownerAccount: "",
|
||||
successOwnerAccount: "",
|
||||
planTier: "enterprise",
|
||||
contractExpiresAt: "",
|
||||
note: "",
|
||||
});
|
||||
|
||||
const accountForm = reactive({
|
||||
account: "",
|
||||
displayName: "",
|
||||
role: "member",
|
||||
password: "",
|
||||
companyId: "",
|
||||
});
|
||||
|
||||
const grantForm = reactive({
|
||||
account: "",
|
||||
scope: "device",
|
||||
targetId: "",
|
||||
templateId: "developer",
|
||||
permissions: ["device.view"],
|
||||
expiresAt: "",
|
||||
note: "",
|
||||
});
|
||||
|
||||
const riskForm = reactive({
|
||||
ownerAccount: "",
|
||||
slaDueAt: "",
|
||||
note: "",
|
||||
});
|
||||
|
||||
const skillRequestForm = reactive({
|
||||
action: "update",
|
||||
deviceId: "",
|
||||
skillId: "",
|
||||
sourceUrl: "",
|
||||
trustedSourceId: "",
|
||||
targetVersion: "",
|
||||
rollbackToVersion: "",
|
||||
lockedVersion: "",
|
||||
checksum: "",
|
||||
note: "",
|
||||
});
|
||||
|
||||
const menuIconMap = {
|
||||
workbench: DashboardOutlined,
|
||||
tenant: ClusterOutlined,
|
||||
user: UserOutlined,
|
||||
role: SafetyOutlined,
|
||||
resource: AppstoreOutlined,
|
||||
skills: ToolOutlined,
|
||||
risk: AuditOutlined,
|
||||
audit: AuditOutlined,
|
||||
system: SettingOutlined,
|
||||
} as const;
|
||||
|
||||
const menuTree = computed(() => payload.value?.menuTree ?? []);
|
||||
const summary = computed(() => payload.value?.workbench.summary ?? {});
|
||||
const tenants = computed(() => payload.value?.tenants ?? []);
|
||||
const users = computed(() => payload.value?.users ?? []);
|
||||
const roles = computed(() => payload.value?.roles.builtInRoles ?? []);
|
||||
const templates = computed(() => payload.value?.roles.permissionTemplates ?? []);
|
||||
const devices = computed(() => payload.value?.resourceGroups.devices ?? []);
|
||||
const projects = computed(() => payload.value?.resourceGroups.projects ?? []);
|
||||
const skills = computed(() => payload.value?.resourceGroups.skills ?? []);
|
||||
const risks = computed(() => payload.value?.audit.risks ?? []);
|
||||
const auditLogs = computed(() => payload.value?.audit.permissionLogs ?? []);
|
||||
const grants = computed(() => payload.value?.resourceGroups.grants ?? { devices: [], projects: [], skills: [] });
|
||||
|
||||
const grantRows = computed(() => [
|
||||
...grants.value.devices.map((grant) => ({ ...grant, scopeLabel: "设备", targetLabel: text(grant.deviceId) })),
|
||||
...grants.value.projects.map((grant) => ({ ...grant, scopeLabel: "项目", targetLabel: text(grant.projectId) })),
|
||||
...grants.value.skills.map((grant) => ({ ...grant, scopeLabel: "Skill", targetLabel: text(grant.skillId) })),
|
||||
]);
|
||||
|
||||
const selectedTemplate = computed(() =>
|
||||
templates.value.find((item) => text(item.templateId) === grantForm.templateId),
|
||||
);
|
||||
|
||||
const selectedScopePermissions = computed(() => {
|
||||
if (grantForm.scope === "project") return selectedTemplate.value?.projectPermissions ?? ["project.view"];
|
||||
if (grantForm.scope === "skill") return selectedTemplate.value?.skillPermissions ?? ["skill.view"];
|
||||
return selectedTemplate.value?.devicePermissions ?? ["device.view"];
|
||||
});
|
||||
|
||||
const selectedScopePermissionPlaceholder = computed(() =>
|
||||
Array.isArray(selectedScopePermissions.value) ? selectedScopePermissions.value.join(" / ") : "device.view",
|
||||
);
|
||||
|
||||
async function loadBackoffice() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
payload.value = await fetchBossAdminBackoffice();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "后台数据加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectMenu(key: string) {
|
||||
activeKey.value = key;
|
||||
}
|
||||
|
||||
function text(value: unknown, fallback = "-") {
|
||||
if (value === null || value === undefined || value === "") return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function permissionText(value: unknown) {
|
||||
return Array.isArray(value) ? value.join(" / ") : text(value);
|
||||
}
|
||||
|
||||
function resetCompanyForm() {
|
||||
Object.assign(companyForm, {
|
||||
companyId: "",
|
||||
name: "",
|
||||
ownerAccount: "",
|
||||
successOwnerAccount: "",
|
||||
planTier: "enterprise",
|
||||
contractExpiresAt: "",
|
||||
note: "",
|
||||
});
|
||||
}
|
||||
|
||||
function resetAccountForm() {
|
||||
Object.assign(accountForm, {
|
||||
account: "",
|
||||
displayName: "",
|
||||
role: "member",
|
||||
password: "",
|
||||
companyId: "",
|
||||
});
|
||||
}
|
||||
|
||||
async function runMutation(label: string, task: () => Promise<unknown>) {
|
||||
mutating.value = true;
|
||||
const hide = message.loading(`${label}中...`, 0);
|
||||
try {
|
||||
await task();
|
||||
hide();
|
||||
message.success(`${label}完成`);
|
||||
await loadBackoffice();
|
||||
} catch (err) {
|
||||
hide();
|
||||
message.error(`${label}失败:${err instanceof Error ? err.message : "UNKNOWN_ERROR"}`);
|
||||
} finally {
|
||||
mutating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCompany() {
|
||||
await runMutation("新建租户", async () => {
|
||||
await postAdminAccess({
|
||||
action: "upsert_company",
|
||||
...companyForm,
|
||||
});
|
||||
resetCompanyForm();
|
||||
});
|
||||
}
|
||||
|
||||
async function setCompanyStatus(record: AdminRecord, status: "active" | "disabled") {
|
||||
await runMutation(status === "active" ? "启用租户" : "停用租户", () =>
|
||||
postAdminAccess({
|
||||
action: "set_company_status",
|
||||
companyId: text(record.companyId),
|
||||
status,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function saveAccount() {
|
||||
await runMutation("新建账号", async () => {
|
||||
await postAdminAccess({
|
||||
action: "upsert_account",
|
||||
...accountForm,
|
||||
});
|
||||
resetAccountForm();
|
||||
});
|
||||
}
|
||||
|
||||
async function setAccountStatus(record: AdminRecord, status: "active" | "disabled") {
|
||||
await runMutation(status === "active" ? "启用账号" : "停用账号", () =>
|
||||
postAdminAccess({
|
||||
action: "set_account_status",
|
||||
account: text(record.account),
|
||||
status,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function resetPassword(record: AdminRecord) {
|
||||
const password = window.prompt(`请输入 ${text(record.account)} 的新密码`);
|
||||
if (!password) return;
|
||||
await runMutation("重置密码", () =>
|
||||
postAdminAccess({
|
||||
action: "reset_account_password",
|
||||
account: text(record.account),
|
||||
password,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function reclaimAccount(record: AdminRecord) {
|
||||
if (!window.confirm(`确认离职回收 ${text(record.account)}?这会停用账号并清理授权。`)) return;
|
||||
await runMutation("离职回收", () =>
|
||||
postAdminAccess({
|
||||
action: "reclaim_account",
|
||||
account: text(record.account),
|
||||
reason: "enterprise-admin-web",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function submitGrant() {
|
||||
const permissions = grantForm.permissions.length > 0 ? grantForm.permissions : selectedScopePermissions.value;
|
||||
const base = {
|
||||
account: grantForm.account,
|
||||
permissions,
|
||||
expiresAt: grantForm.expiresAt,
|
||||
note: grantForm.note,
|
||||
};
|
||||
await runMutation("分配资源", async () => {
|
||||
if (grantForm.scope === "project") {
|
||||
await postAdminAccess({ action: "grant_project", ...base, projectId: grantForm.targetId });
|
||||
} else if (grantForm.scope === "skill") {
|
||||
await postAdminAccess({ action: "grant_skill", ...base, skillId: grantForm.targetId });
|
||||
} else {
|
||||
await postAdminAccess({ action: "grant_device", ...base, deviceId: grantForm.targetId });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function applyPermissionTemplate() {
|
||||
await runMutation("套用权限模板", () =>
|
||||
postAdminAccess({
|
||||
action: "apply_template",
|
||||
account: grantForm.account,
|
||||
templateId: grantForm.templateId,
|
||||
deviceIds: grantForm.scope === "device" ? [grantForm.targetId] : [],
|
||||
projectIds: grantForm.scope === "project" ? [grantForm.targetId] : [],
|
||||
skillIds: grantForm.scope === "skill" ? [grantForm.targetId] : [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function revokeGrant(record: AdminRecord) {
|
||||
if (!window.confirm(`确认撤销授权 ${text(record.grantId)}?`)) return;
|
||||
await runMutation("撤销授权", () =>
|
||||
postAdminAccess({
|
||||
action: "revoke_grant",
|
||||
grantId: text(record.grantId),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleRisk(record: AdminRecord, action: string) {
|
||||
await runMutation(
|
||||
action === "assign_owner"
|
||||
? "指派负责人"
|
||||
: action === "set_sla"
|
||||
? "设置 SLA"
|
||||
: action === "ack"
|
||||
? "确认风险"
|
||||
: action === "resolve"
|
||||
? "关闭风险"
|
||||
: "创建工单",
|
||||
() =>
|
||||
postRiskAction({
|
||||
riskId: text(record.riskId),
|
||||
action,
|
||||
ownerAccount: riskForm.ownerAccount,
|
||||
slaDueAt: riskForm.slaDueAt,
|
||||
note: riskForm.note,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function createSkillRequest() {
|
||||
await runMutation("创建 Skill 请求", () =>
|
||||
postSkillLifecycleRequest({
|
||||
...skillRequestForm,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(loadBackoffice);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider
|
||||
:theme="{
|
||||
token: {
|
||||
colorPrimary: '#10b981',
|
||||
borderRadius: 16,
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="boss-admin-shell">
|
||||
<aside class="boss-admin-sidebar">
|
||||
<div class="boss-admin-brand">
|
||||
<div class="boss-admin-brand-mark">B</div>
|
||||
<div>
|
||||
<h1>Boss 企业后台</h1>
|
||||
<p>平台侧 To B 管理中心</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="boss-admin-menu" aria-label="企业后台菜单">
|
||||
<button
|
||||
v-for="item in menuTree"
|
||||
:key="item.key"
|
||||
class="boss-admin-menu-item"
|
||||
:class="{ active: activeKey === item.key }"
|
||||
type="button"
|
||||
@click="selectMenu(item.key)"
|
||||
>
|
||||
<component :is="menuIconMap[item.key as keyof typeof menuIconMap] ?? TeamOutlined" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="boss-admin-main">
|
||||
<header class="boss-admin-header">
|
||||
<div>
|
||||
<p class="boss-admin-eyebrow">YuDao / Vben 信息架构 · Boss 数据契约</p>
|
||||
<h2>{{ menuTree.find((item) => item.key === activeKey)?.label ?? "工作台" }}</h2>
|
||||
</div>
|
||||
<div class="boss-admin-header-actions">
|
||||
<a-tag color="green">highest_admin</a-tag>
|
||||
<a-button :loading="loading" @click="loadBackoffice">刷新</a-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<a-alert
|
||||
v-if="error"
|
||||
class="boss-admin-alert"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="`后台数据加载失败:${error}`"
|
||||
/>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<section v-if="activeKey === 'workbench'" class="boss-admin-section-grid">
|
||||
<a-card class="boss-admin-hero" :bordered="false">
|
||||
<p class="boss-admin-eyebrow">总览统计</p>
|
||||
<h3>公司、账号、电脑节点和风险一张图看清</h3>
|
||||
<div class="boss-admin-metrics">
|
||||
<a-statistic title="公司数" :value="summary.companies ?? 0" />
|
||||
<a-statistic title="账号数" :value="summary.accounts ?? 0" />
|
||||
<a-statistic title="在线设备" :value="summary.onlineDevices ?? 0" />
|
||||
<a-statistic title="开放风险" :value="summary.openRisks ?? 0" />
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="关键风险" :bordered="false">
|
||||
<a-table size="small" :pagination="false" :data-source="risks.slice(0, 5)" row-key="riskId">
|
||||
<a-table-column title="风险" data-index="title" />
|
||||
<a-table-column title="级别" data-index="severity" />
|
||||
<a-table-column title="对象" data-index="deviceId" />
|
||||
<a-table-column title="时间" data-index="lastSeenAt" />
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-card title="客户健康排行" :bordered="false">
|
||||
<a-table size="small" :pagination="false" :data-source="tenants.slice(0, 6)" row-key="companyId">
|
||||
<a-table-column title="公司" data-index="name" />
|
||||
<a-table-column title="账号" data-index="accountCount" />
|
||||
<a-table-column title="设备" data-index="deviceCount" />
|
||||
<a-table-column title="风险" data-index="openRiskCount" />
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-card title="节点健康" :bordered="false">
|
||||
<a-table size="small" :pagination="false" :data-source="devices.slice(0, 8)" row-key="id">
|
||||
<a-table-column title="设备" data-index="name" />
|
||||
<a-table-column title="状态" data-index="status" />
|
||||
<a-table-column title="CLI" data-index="codexCliOnline" />
|
||||
<a-table-column title="GUI" data-index="codexGuiOnline" />
|
||||
</a-table>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeKey === 'tenant'" class="boss-admin-section-grid">
|
||||
<a-card title="新建租户" :bordered="false">
|
||||
<a-form layout="vertical" class="boss-admin-form">
|
||||
<a-form-item label="租户 ID">
|
||||
<a-input v-model:value="companyForm.companyId" placeholder="acme" />
|
||||
</a-form-item>
|
||||
<a-form-item label="公司名称">
|
||||
<a-input v-model:value="companyForm.name" placeholder="默认显示给平台运营人员" />
|
||||
</a-form-item>
|
||||
<a-form-item label="老板账号">
|
||||
<a-input v-model:value="companyForm.ownerAccount" placeholder="owner@example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item label="客户成功">
|
||||
<a-input v-model:value="companyForm.successOwnerAccount" placeholder="cs@boss.com" />
|
||||
</a-form-item>
|
||||
<a-form-item label="套餐">
|
||||
<a-select v-model:value="companyForm.planTier">
|
||||
<a-select-option value="trial">trial</a-select-option>
|
||||
<a-select-option value="standard">standard</a-select-option>
|
||||
<a-select-option value="enterprise">enterprise</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="合同到期">
|
||||
<a-input v-model:value="companyForm.contractExpiresAt" placeholder="2027-05-01T00:00:00+08:00" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" block :loading="mutating" @click="saveCompany">新建租户</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card title="租户管理" :bordered="false">
|
||||
<a-table :data-source="tenants" row-key="companyId">
|
||||
<a-table-column title="公司" data-index="name" />
|
||||
<a-table-column title="套餐" data-index="planTier" />
|
||||
<a-table-column title="老板账号" data-index="ownerAccount" />
|
||||
<a-table-column title="账号数" data-index="accountCount" />
|
||||
<a-table-column title="开放风险" data-index="openRiskCount" />
|
||||
<a-table-column title="操作">
|
||||
<template #default="{ record }">
|
||||
<a-space>
|
||||
<a-button size="small" @click="setCompanyStatus(record, 'active')">启用租户</a-button>
|
||||
<a-button size="small" danger @click="setCompanyStatus(record, 'disabled')">停用租户</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeKey === 'user'" class="boss-admin-section-grid">
|
||||
<a-card title="新建账号" :bordered="false">
|
||||
<a-form layout="vertical" class="boss-admin-form">
|
||||
<a-form-item label="账号">
|
||||
<a-input v-model:value="accountForm.account" placeholder="member@example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item label="昵称">
|
||||
<a-input v-model:value="accountForm.displayName" placeholder="成员姓名" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色">
|
||||
<a-select v-model:value="accountForm.role">
|
||||
<a-select-option value="member">member</a-select-option>
|
||||
<a-select-option value="admin">admin</a-select-option>
|
||||
<a-select-option value="highest_admin">highest_admin</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="所属租户">
|
||||
<a-select v-model:value="accountForm.companyId" allow-clear>
|
||||
<a-select-option v-for="company in tenants" :key="text(company.companyId)" :value="text(company.companyId)">
|
||||
{{ text(company.name) }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="初始密码">
|
||||
<a-input-password v-model:value="accountForm.password" placeholder="留空时只更新资料" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" block :loading="mutating" @click="saveAccount">新建账号</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card title="账号管理" :bordered="false">
|
||||
<a-table :data-source="users" row-key="id">
|
||||
<a-table-column title="账号" data-index="account" />
|
||||
<a-table-column title="昵称" data-index="displayName" />
|
||||
<a-table-column title="角色" data-index="role" />
|
||||
<a-table-column title="公司" data-index="companyName" />
|
||||
<a-table-column title="状态" data-index="status" />
|
||||
<a-table-column title="操作">
|
||||
<template #default="{ record }">
|
||||
<a-space wrap>
|
||||
<a-button size="small" @click="setAccountStatus(record, 'active')">启用账号</a-button>
|
||||
<a-button size="small" @click="resetPassword(record)">重置密码</a-button>
|
||||
<a-button size="small" danger @click="setAccountStatus(record, 'disabled')">停用账号</a-button>
|
||||
<a-button size="small" danger @click="reclaimAccount(record)">离职回收</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeKey === 'role'" class="boss-admin-section-grid">
|
||||
<a-card title="角色权限" :bordered="false">
|
||||
<a-list :data-source="roles">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta :title="text(item.label)" :description="text(item.description)" />
|
||||
<a-tag>{{ item.role }}</a-tag>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
<a-card title="权限模板" :bordered="false">
|
||||
<a-list :data-source="templates">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta :title="text(item.name)" :description="text(item.description)" />
|
||||
<a-tag color="green">{{ item.templateId }}</a-tag>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeKey === 'resource'" class="boss-admin-section-grid">
|
||||
<div class="boss-admin-section-title">资源授权</div>
|
||||
<a-card title="分配资源" :bordered="false">
|
||||
<a-form layout="vertical" class="boss-admin-form">
|
||||
<a-form-item label="账号">
|
||||
<a-select v-model:value="grantForm.account" show-search>
|
||||
<a-select-option v-for="user in users" :key="text(user.account)" :value="text(user.account)">
|
||||
{{ text(user.displayName) }} · {{ text(user.account) }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="授权范围">
|
||||
<a-segmented v-model:value="grantForm.scope" :options="['device', 'project', 'skill']" />
|
||||
</a-form-item>
|
||||
<a-form-item label="目标">
|
||||
<a-select v-model:value="grantForm.targetId" show-search>
|
||||
<a-select-option
|
||||
v-for="device in devices"
|
||||
v-if="grantForm.scope === 'device'"
|
||||
:key="text(device.id)"
|
||||
:value="text(device.id)"
|
||||
>
|
||||
{{ text(device.name) }}
|
||||
</a-select-option>
|
||||
<a-select-option
|
||||
v-for="project in projects"
|
||||
v-if="grantForm.scope === 'project'"
|
||||
:key="text(project.id)"
|
||||
:value="text(project.id)"
|
||||
>
|
||||
{{ text(project.name) }}
|
||||
</a-select-option>
|
||||
<a-select-option
|
||||
v-for="skill in skills"
|
||||
v-if="grantForm.scope === 'skill'"
|
||||
:key="text(skill.skillId)"
|
||||
:value="text(skill.skillId)"
|
||||
>
|
||||
{{ text(skill.name) }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="权限模板">
|
||||
<a-select v-model:value="grantForm.templateId">
|
||||
<a-select-option v-for="template in templates" :key="text(template.templateId)" :value="text(template.templateId)">
|
||||
{{ text(template.name) }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="权限">
|
||||
<a-select v-model:value="grantForm.permissions" mode="tags" :placeholder="selectedScopePermissionPlaceholder" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-input v-model:value="grantForm.note" />
|
||||
</a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="mutating" @click="submitGrant">分配资源</a-button>
|
||||
<a-button :loading="mutating" @click="applyPermissionTemplate">套用权限模板</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card title="授权清单" :bordered="false">
|
||||
<a-table :data-source="grantRows" row-key="grantId">
|
||||
<a-table-column title="范围" data-index="scopeLabel" />
|
||||
<a-table-column title="账号" data-index="account" />
|
||||
<a-table-column title="目标" data-index="targetLabel" />
|
||||
<a-table-column title="权限">
|
||||
<template #default="{ record }">
|
||||
{{ permissionText(record.permissions) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作">
|
||||
<template #default="{ record }">
|
||||
<a-button size="small" danger @click="revokeGrant(record)">撤销授权</a-button>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeKey === 'skills'" class="boss-admin-section-grid">
|
||||
<a-card title="创建 Skill 请求" :bordered="false">
|
||||
<a-form layout="vertical" class="boss-admin-form">
|
||||
<a-form-item label="动作">
|
||||
<a-select v-model:value="skillRequestForm.action">
|
||||
<a-select-option value="install">install</a-select-option>
|
||||
<a-select-option value="update">update</a-select-option>
|
||||
<a-select-option value="uninstall">uninstall</a-select-option>
|
||||
<a-select-option value="rollback">rollback</a-select-option>
|
||||
<a-select-option value="version_lock">version_lock</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备">
|
||||
<a-select v-model:value="skillRequestForm.deviceId" show-search>
|
||||
<a-select-option v-for="device in devices" :key="text(device.id)" :value="text(device.id)">
|
||||
{{ text(device.name) }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Skill">
|
||||
<a-select v-model:value="skillRequestForm.skillId" allow-clear show-search>
|
||||
<a-select-option v-for="skill in skills" :key="text(skill.skillId)" :value="text(skill.skillId)">
|
||||
{{ text(skill.name) }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="来源 URL">
|
||||
<a-input v-model:value="skillRequestForm.sourceUrl" placeholder="安装或更新远端来源" />
|
||||
</a-form-item>
|
||||
<a-form-item label="版本 / 回滚 / 锁定">
|
||||
<a-input-group compact>
|
||||
<a-input v-model:value="skillRequestForm.targetVersion" style="width: 33%" placeholder="targetVersion" />
|
||||
<a-input v-model:value="skillRequestForm.rollbackToVersion" style="width: 33%" placeholder="rollbackToVersion" />
|
||||
<a-input v-model:value="skillRequestForm.lockedVersion" style="width: 34%" placeholder="lockedVersion" />
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="校验和 / 备注">
|
||||
<a-input v-model:value="skillRequestForm.checksum" placeholder="sha256 checksum" />
|
||||
<a-input v-model:value="skillRequestForm.note" class="boss-admin-form-gap" placeholder="备注" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" block :loading="mutating" @click="createSkillRequest">创建 Skill 请求</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card title="Skill 中心" :bordered="false">
|
||||
<a-table :data-source="skills" row-key="skillId">
|
||||
<a-table-column title="Skill" data-index="name" />
|
||||
<a-table-column title="说明" data-index="description" />
|
||||
<a-table-column title="分类" data-index="category" />
|
||||
<a-table-column title="设备数" data-index="deviceCount" />
|
||||
<a-table-column title="更新时间" data-index="updatedAt" />
|
||||
</a-table>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeKey === 'risk'" class="boss-admin-section">
|
||||
<a-card title="风险告警与处置" :bordered="false">
|
||||
<div class="boss-admin-action-strip">
|
||||
<a-input v-model:value="riskForm.ownerAccount" placeholder="负责人账号,用于指派负责人" />
|
||||
<a-input v-model:value="riskForm.slaDueAt" placeholder="SLA 时间,如 2026-05-02T18:00:00+08:00" />
|
||||
<a-input v-model:value="riskForm.note" placeholder="处理备注" />
|
||||
</div>
|
||||
<a-table :data-source="risks" row-key="riskId">
|
||||
<a-table-column title="风险" data-index="title" />
|
||||
<a-table-column title="级别" data-index="severity" />
|
||||
<a-table-column title="公司" data-index="companyId" />
|
||||
<a-table-column title="负责人" data-index="ownerAccount" />
|
||||
<a-table-column title="SLA" data-index="slaDueAt" />
|
||||
<a-table-column title="操作">
|
||||
<template #default="{ record }">
|
||||
<a-space wrap>
|
||||
<a-button size="small" @click="handleRisk(record, 'assign_owner')">指派负责人</a-button>
|
||||
<a-button size="small" @click="handleRisk(record, 'set_sla')">设置 SLA</a-button>
|
||||
<a-button size="small" @click="handleRisk(record, 'ack')">确认风险</a-button>
|
||||
<a-button size="small" danger @click="handleRisk(record, 'resolve')">关闭风险</a-button>
|
||||
<a-button size="small" @click="handleRisk(record, 'create_repair_ticket')">创建工单</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeKey === 'audit'" class="boss-admin-section">
|
||||
<a-card title="审计日志" :bordered="false">
|
||||
<a-table :data-source="auditLogs" row-key="auditId">
|
||||
<a-table-column title="操作人" data-index="actorAccount" />
|
||||
<a-table-column title="动作" data-index="action" />
|
||||
<a-table-column title="对象账号" data-index="targetAccount" />
|
||||
<a-table-column title="详情" data-index="detail" />
|
||||
<a-table-column title="时间" data-index="createdAt" />
|
||||
</a-table>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else class="boss-admin-section">
|
||||
<a-card title="系统设置" :bordered="false">
|
||||
<p>当前独立后台已接入 Boss Admin BFF,并已具备租户、账号、授权、风险和 Skill 的基础治理动作。</p>
|
||||
<a-descriptions bordered size="small" :column="1">
|
||||
<a-descriptions-item v-for="(value, key) in payload?.yudaoMapping ?? {}" :key="key" :label="key">
|
||||
{{ value }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</section>
|
||||
</a-spin>
|
||||
</main>
|
||||
</div>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
83
apps/boss-admin-web/src/api/bossAdmin.ts
Normal file
83
apps/boss-admin-web/src/api/bossAdmin.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export interface BossAdminMenuItem {
|
||||
key: string;
|
||||
label: string;
|
||||
children?: BossAdminMenuItem[];
|
||||
}
|
||||
|
||||
export interface BossAdminBackofficePayload {
|
||||
ok: boolean;
|
||||
menuTree: BossAdminMenuItem[];
|
||||
workbench: {
|
||||
summary: Record<string, number>;
|
||||
companies: Array<Record<string, unknown>>;
|
||||
devices: Array<Record<string, unknown>>;
|
||||
risks: Array<Record<string, unknown>>;
|
||||
notifications: Array<Record<string, unknown>>;
|
||||
grantsSummary: Record<string, number>;
|
||||
};
|
||||
tenants: Array<Record<string, unknown>>;
|
||||
users: Array<Record<string, unknown>>;
|
||||
roles: {
|
||||
builtInRoles: Array<Record<string, unknown>>;
|
||||
permissionTemplates: Array<Record<string, unknown>>;
|
||||
};
|
||||
resourceGroups: {
|
||||
devices: Array<Record<string, unknown>>;
|
||||
projects: Array<Record<string, unknown>>;
|
||||
skills: Array<Record<string, unknown>>;
|
||||
grants: Record<string, Array<Record<string, unknown>>>;
|
||||
};
|
||||
audit: {
|
||||
risks: Array<Record<string, unknown>>;
|
||||
notifications: Array<Record<string, unknown>>;
|
||||
riskTimeline: Array<Record<string, unknown>>;
|
||||
permissionLogs: Array<Record<string, unknown>>;
|
||||
};
|
||||
yudaoMapping: Record<string, string>;
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (response.status === 401) {
|
||||
window.location.href = "/auth/login";
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.message ?? `HTTP_${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchBossAdminBackoffice(): Promise<BossAdminBackofficePayload> {
|
||||
return requestJson<BossAdminBackofficePayload>("/api/v1/admin/backoffice");
|
||||
}
|
||||
|
||||
export async function postAdminAccess(payload: Record<string, unknown>) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/access", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function postRiskAction(payload: Record<string, unknown>) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/risks/actions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function postSkillLifecycleRequest(payload: Record<string, unknown>) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/skills/requests", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
7
apps/boss-admin-web/src/main.ts
Normal file
7
apps/boss-admin-web/src/main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createApp } from "vue";
|
||||
import Antd from "ant-design-vue";
|
||||
import "ant-design-vue/dist/reset.css";
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
createApp(App).use(Antd).mount("#app");
|
||||
174
apps/boss-admin-web/src/styles.css
Normal file
174
apps/boss-admin-web/src/styles.css
Normal file
@@ -0,0 +1,174 @@
|
||||
:root {
|
||||
color: #102018;
|
||||
background: #eef4ef;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 1280px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(16, 185, 129, 0.16), transparent 30%),
|
||||
linear-gradient(135deg, #f7fbf8 0%, #eef4ef 55%, #e8f1ed 100%);
|
||||
}
|
||||
|
||||
.boss-admin-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.boss-admin-sidebar {
|
||||
padding: 28px 20px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border-right: 1px solid rgba(16, 32, 24, 0.08);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.boss-admin-brand {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.boss-admin-brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
background: #10b981;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 14px 28px rgba(16, 185, 129, 0.24);
|
||||
}
|
||||
|
||||
.boss-admin-brand h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.boss-admin-brand p,
|
||||
.boss-admin-eyebrow {
|
||||
margin: 0;
|
||||
color: #6b766f;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.boss-admin-menu {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.boss-admin-menu-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
color: #56615a;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.boss-admin-menu-item.active,
|
||||
.boss-admin-menu-item:hover {
|
||||
color: #0f7a55;
|
||||
background: #e7f8ef;
|
||||
}
|
||||
|
||||
.boss-admin-main {
|
||||
padding: 28px 34px 44px;
|
||||
}
|
||||
|
||||
.boss-admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.boss-admin-header h2 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.boss-admin-header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.boss-admin-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.boss-admin-section,
|
||||
.boss-admin-section-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.boss-admin-section-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.boss-admin-section-title {
|
||||
grid-column: 1 / -1;
|
||||
color: #25342b;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.boss-admin-hero {
|
||||
grid-column: 1 / -1;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(16, 185, 129, 0.16), rgba(255, 255, 255, 0.92)),
|
||||
white;
|
||||
}
|
||||
|
||||
.boss-admin-hero h3 {
|
||||
margin: 8px 0 20px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.boss-admin-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.boss-admin-metrics .ant-statistic {
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
border: 1px solid rgba(16, 32, 24, 0.08);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.boss-admin-form {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.boss-admin-form-gap {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.boss-admin-action-strip {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 320px 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
box-shadow: 0 18px 50px rgba(16, 32, 24, 0.07);
|
||||
}
|
||||
17
apps/boss-admin-web/tsconfig.json
Normal file
17
apps/boss-admin-web/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
23
apps/boss-admin-web/vite.config.ts
Normal file
23
apps/boss-admin-web/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/admin-web/",
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: "../../public/admin-web",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.BOSS_WEB_ORIGIN ?? "http://localhost:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/auth": {
|
||||
target: process.env.BOSS_WEB_ORIGIN ?? "http://localhost:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user