feat: ship enterprise control and desktop governance

This commit is contained in:
AI Bot
2026-05-11 14:59:26 +08:00
parent 0757d07521
commit a311280238
285 changed files with 48574 additions and 2428 deletions

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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>

View 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),
});
}

View 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");

View 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);
}

View 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"]
}

View 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,
},
},
},
});