完整可交互的后台管理工程:顶部 header(搜索 / 消息铃铛 / 语言 / 主题齿轮 / 用户头像下拉)+ 左侧菜单 / 顶栏 / 折叠侧栏三种菜单形态、三套主题、两种密度、五种主色、中英文双语;用户 / 角色 / 用户角色 / 字典 / 操作日志 / 登录日志 7 个真实页面,可直接在演示里点开个人中心 / 修改密码 / CRUD 弹窗 / 权限分配 / 搜索等交互。
<script setup lang="ts">
/**
* admin-mini · 实时演示。
* 顶部 AdminHeader(搜索 / 消息铃铛 / 语言 / 主题齿轮 / 用户头像下拉);
* 中间 CfAppShell + CfSidebar / CfNavMenu + CfBreadcrumb;
* 右抽屉 SettingsDrawer 装主题/密度/菜单/主色;个人中心 + 修改密码用 Modal;
* 7 个子页面随菜单切换。
*/
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
import { CfAppShell, CfSidebar, CfNavMenu, CfBreadcrumb, CfCommandPalette, CfTour, toast } from '@chufix-design/vue';
import type { SidebarEntry, SidebarItem, CommandPaletteItem, TourStep } from '@chufix-design/vue';
import AdminHeader from './AdminHeader.vue';
import Login from './Login.vue';
import SettingsDrawer from './SettingsDrawer.vue';
import ProfileModal from './ProfileModal.vue';
import ChangePasswordModal from './ChangePasswordModal.vue';
import Dashboard from './pages/Dashboard.vue';
import Users from './pages/Users.vue';
import Roles from './pages/Roles.vue';
import UserRoles from './pages/UserRoles.vue';
import Dict from './pages/Dict.vue';
import OperationLog from './pages/OperationLog.vue';
import LoginLog from './pages/LoginLog.vue';
import SystemSettings from './pages/SystemSettings.vue';
import Org from './pages/Org.vue';
import Menus from './pages/Menus.vue';
import {
ACCENT_HUE,
DemoStateKey,
STRINGS,
type DemoTheme,
type DemoDensity,
type DemoMenuForm,
type DemoAccent,
type DemoLocale,
} from './state';
type RouteId = 'dashboard' | 'users' | 'roles' | 'user-roles' | 'org' | 'dict' | 'op-log' | 'login-log' | 'menus' | 'sys-settings';
/* 默认主题尽量跟宿主页面一致:SSR 时先放 dark-cool 占位,hydrate 后用 onMounted 读取宿主 <html> / <body> 上的 data-theme 覆盖。 */
const theme = ref<DemoTheme>('dark-cool');
const density = ref<DemoDensity>('comfortable');
const menuForm = ref<DemoMenuForm>('sidebar');
const accent = ref<DemoAccent>('blue');
const locale = ref<DemoLocale>('zh');
const route = ref<RouteId>('dashboard');
provide(DemoStateKey, { theme, density, menuForm, accent, locale });
const t = computed(() => STRINGS[locale.value]);
// 顶部交互入口
const settingsOpen = ref(false);
const profileOpen = ref(false);
const passwordOpen = ref(false);
const paletteOpen = ref(false);
const tourOpen = ref(false);
/* ---------- 登录态 ---------- */
const AUTH_KEY = 'chufix-tpl:admin-mini:auth';
const authedUser = ref<string | null>(null);
function onLoginSuccess(payload: { username: string; remember: boolean }) {
authedUser.value = payload.username;
try {
if (payload.remember) window.sessionStorage.setItem(AUTH_KEY, payload.username);
else window.sessionStorage.removeItem(AUTH_KEY);
} catch { /* storage blocked */ }
toast.success(t.value.login_welcome.replace('{user}', payload.username));
// 首次登录后弹出引导(同一 session 只弹一次)
try {
if (!window.sessionStorage.getItem('chufix-tpl:admin-mini:tour-done')) {
setTimeout(() => (tourOpen.value = true), 600);
window.sessionStorage.setItem('chufix-tpl:admin-mini:tour-done', '1');
}
} catch { /* */ }
}
function onLogout() {
authedUser.value = null;
try { window.sessionStorage.removeItem(AUTH_KEY); } catch { /* */ }
toast.info(t.value.logout_done);
}
/* ---------- 全局快捷键:Ctrl/⌘ + K 打开 command palette ---------- */
function onGlobalKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
paletteOpen.value = !paletteOpen.value;
}
}
/* ---------- 主题:从宿主页面继承默认值;把当前选择同步到 body 让 Teleport 出去的弹层(Modal / Drawer / Tour / Popover)也继承同一套主题。 ---------- */
const HOST_THEME_BACKUP_KEY = '__chufix_admin_mini_theme_backup__';
function applyToBody() {
if (typeof document === 'undefined') return;
const body = document.body;
body.setAttribute('data-theme', theme.value);
body.setAttribute('data-density', density.value);
body.style.setProperty('--accent-1', ACCENT_HUE[accent.value]);
}
function restoreHostTheme() {
if (typeof document === 'undefined') return;
const body = document.body;
const backup = (window as unknown as Record<string, unknown>)[HOST_THEME_BACKUP_KEY] as
| { theme: string | null; density: string | null; accent: string }
| undefined;
if (!backup) return;
if (backup.theme !== null) body.setAttribute('data-theme', backup.theme);
else body.removeAttribute('data-theme');
if (backup.density !== null) body.setAttribute('data-density', backup.density);
else body.removeAttribute('data-density');
if (backup.accent) body.style.setProperty('--accent-1', backup.accent);
else body.style.removeProperty('--accent-1');
delete (window as unknown as Record<string, unknown>)[HOST_THEME_BACKUP_KEY];
}
onMounted(() => {
if (typeof window !== 'undefined') {
window.addEventListener('keydown', onGlobalKey);
// 备份宿主主题,以便卸载时还原
const body = document.body;
(window as unknown as Record<string, unknown>)[HOST_THEME_BACKUP_KEY] = {
theme: body.getAttribute('data-theme'),
density: body.getAttribute('data-density'),
accent: body.style.getPropertyValue('--accent-1') || '',
};
// 用宿主主题作为初始值(如果是已知值),否则保留组件默认 dark-cool
const hostTheme = body.getAttribute('data-theme') ?? document.documentElement.getAttribute('data-theme');
if (hostTheme === 'dark-cool' || hostTheme === 'dark-warm' || hostTheme === 'light' || hostTheme === 'dark') {
theme.value = hostTheme === 'dark' ? 'dark-cool' : (hostTheme as DemoTheme);
}
const hostDensity = body.getAttribute('data-density');
if (hostDensity === 'comfortable' || hostDensity === 'compact') {
density.value = hostDensity;
}
applyToBody();
// 恢复登录态(同一标签页刷新不需要重新登录)
try {
const saved = window.sessionStorage.getItem(AUTH_KEY);
if (saved) authedUser.value = saved;
} catch { /* */ }
// 首次访问自动启动 tour,sessionStorage 标志防止反复弹(仅登录后才弹)
try {
if (authedUser.value && !window.sessionStorage.getItem('chufix-tpl:admin-mini:tour-done')) {
setTimeout(() => (tourOpen.value = true), 400);
window.sessionStorage.setItem('chufix-tpl:admin-mini:tour-done', '1');
}
} catch { /* sessionStorage blocked */ }
}
});
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', onGlobalKey);
restoreHostTheme();
}
});
watch([theme, density, accent], applyToBody);
const tourSteps = computed<TourStep[]>(() => [
{ target: '[data-tour="brand"]', title: t.value.tour_brand_title, description: t.value.tour_brand_desc, placement: 'bottom' },
{ target: '[data-tour="search"]', title: t.value.tour_search_title, description: t.value.tour_search_desc, placement: 'bottom' },
{ target: '[data-tour="settings"]', title: t.value.tour_settings_title, description: t.value.tour_settings_desc, placement: 'bottom' },
{ target: '[data-tour="user"]', title: t.value.tour_user_title, description: t.value.tour_user_desc, placement: 'bottom' },
]);
const sidebarItems = computed<SidebarEntry[]>(() => [
{
type: 'group',
label: t.value.grp_overview,
items: [
{ key: 'dashboard', label: t.value.page_dashboard, icon: iconSvg('M3 13h6V3H3v10zm0 8h6v-6H3v6zm8 0h10V11H11v10zm0-18v6h10V3H11z') },
],
},
{
type: 'group',
label: t.value.grp_authz,
items: [
{ key: 'users', label: t.value.page_users, icon: iconSvg('M12 12a4 4 0 100-8 4 4 0 000 8zm-8 9a8 8 0 0116 0H4z') },
{ key: 'roles', label: t.value.page_roles, icon: iconSvg('M12 2l9 4v6c0 5-3.5 9-9 10-5.5-1-9-5-9-10V6l9-4z') },
{ key: 'user-roles', label: t.value.page_user_roles, icon: iconSvg('M9 12a4 4 0 100-8 4 4 0 000 8zm6 0a3 3 0 100-6 3 3 0 000 6zm-6 2c-3 0-9 1.5-9 4.5V21h12v-2.5c0-1.6 1.7-2.7 3.5-3.3-.6-.7-1.7-1.2-3-1.2zm6 .5c2 0 6 1 6 3V21h-6v-3.5z') },
{ key: 'org', label: t.value.page_org, icon: iconSvg('M3 21V8h6V3h6v5h6v13H3zm2-2h4v-3H5v3zm6 0h4v-3h-4v3zm6 0h4v-3h-4v3zM5 14h4v-3H5v3zm6 0h4v-3h-4v3zm6 0h4v-3h-4v3zM11 8h4V5h-4v3z') },
],
},
{
type: 'group',
label: t.value.grp_system,
items: [
{ key: 'dict', label: t.value.page_dict, icon: iconSvg('M4 4h16v3H4zM4 10h16v3H4zM4 16h16v3H4z') },
{ key: 'op-log', label: t.value.page_op_log, icon: iconSvg('M5 3h11l3 3v15H5z M14 3v4h4') },
{ key: 'login-log', label: t.value.page_login_log, icon: iconSvg('M10 17l5-5-5-5v3H3v4h7v3z M21 3h-8v18h8V3z') },
{ key: 'menus', label: t.value.page_menus, icon: iconSvg('M3 5h6v6H3zm0 8h6v6H3zm10-8h6v6h-6zm0 8h6v6h-6z') },
{ key: 'sys-settings', label: t.value.page_sys_settings, icon: iconSvg('M12 8a4 4 0 100 8 4 4 0 000-8zm9.4 4a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09a1.65 1.65 0 00-1-1.51 1.65 1.65 0 00-1.82.33l-.06.06A2 2 0 113.39 16.96l.06-.06a1.65 1.65 0 00.33-1.82A1.65 1.65 0 002.27 14H3a2 2 0 110-4h-.09c.36 0 .68-.13 1-.34A1.65 1.65 0 003.78 8 1.65 1.65 0 003.45 6.18l-.06-.06a2 2 0 112.83-2.83l.06.06c.5.5 1.24.63 1.82.33H8c.36 0 .68-.13 1-.34A1.65 1.65 0 0010 2.27V3a2 2 0 114 0v-.09c0 .36.13.68.34 1A1.65 1.65 0 0016 3.78a1.65 1.65 0 011.82.33l.06.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0021.4 9z') },
],
},
]);
const navMenuItems = computed(() => {
const groups: SidebarEntry[] = sidebarItems.value;
const out: { key: string; label: string; href?: string }[] = [];
for (const g of groups) {
if ('type' in g && g.type === 'group' && g.items) {
for (const item of g.items) {
out.push({ key: item.key, label: item.label, href: '#' + item.key });
}
}
}
return out;
});
const breadcrumbItems = computed(() => {
const item = flatItem(route.value);
const groupLabel = findGroupLabel(route.value);
const items: { label: string }[] = [{ label: t.value.brand }];
if (groupLabel) items.push({ label: groupLabel });
if (item) items.push({ label: item.label });
return items;
});
function flatItem(key: string): SidebarItem | null {
for (const g of sidebarItems.value) {
if ('type' in g && g.type === 'group') {
const found = g.items.find((i) => i.key === key);
if (found) return found;
}
}
return null;
}
function findGroupLabel(key: string): string | undefined {
for (const g of sidebarItems.value) {
if ('type' in g && g.type === 'group') {
if (g.items.some((i) => i.key === key)) return g.label;
}
}
return undefined;
}
function iconSvg(d: string) {
return `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
}
const shellStyle = computed(() => ({
'--accent-1': ACCENT_HUE[accent.value],
}));
const showSidebar = computed(() => menuForm.value !== 'topbar');
const sidebarCollapsed = computed(() => menuForm.value === 'collapsed');
const paletteItems = computed<CommandPaletteItem[]>(() => {
const navs: CommandPaletteItem[] = [];
for (const g of sidebarItems.value) {
if ('type' in g && g.type === 'group') {
for (const item of g.items) {
navs.push({
id: `nav:${item.key}`,
label: item.label,
group: t.value.cmd_navigate,
keywords: [item.key],
});
}
}
}
return [
...navs,
{ id: 'act:settings', label: t.value.cmd_open_settings, group: t.value.cmd_actions, shortcut: '⇧S' },
{ id: 'act:profile', label: t.value.cmd_open_profile, group: t.value.cmd_actions },
{ id: 'act:password', label: t.value.cmd_change_password, group: t.value.cmd_actions },
{ id: 'act:logout', label: t.value.cmd_logout, group: t.value.cmd_actions },
];
});
function onPaletteSelect(id: string) {
paletteOpen.value = false;
if (id.startsWith('nav:')) {
route.value = id.slice(4) as RouteId;
} else if (id === 'act:settings') settingsOpen.value = true;
else if (id === 'act:profile') profileOpen.value = true;
else if (id === 'act:password') passwordOpen.value = true;
else if (id === 'act:logout') onLogout();
}
const pageComp = computed(() => {
switch (route.value) {
case 'dashboard': return Dashboard;
case 'users': return Users;
case 'roles': return Roles;
case 'user-roles': return UserRoles;
case 'org': return Org;
case 'dict': return Dict;
case 'op-log': return OperationLog;
case 'login-log': return LoginLog;
case 'menus': return Menus;
case 'sys-settings': return SystemSettings;
default: return Dashboard;
}
});
</script>
<template>
<div
class="adm-root"
:data-theme="theme"
:data-density="density"
:style="shellStyle"
>
<Login v-if="!authedUser" @login-success="onLoginSuccess" />
<template v-else>
<CfAppShell
:sidebar-collapsed="sidebarCollapsed"
:sidebar-width="sidebarCollapsed ? 64 : 220"
:header-height="56"
>
<template #header>
<AdminHeader
@open-settings="settingsOpen = true"
@open-profile="profileOpen = true"
@open-change-password="passwordOpen = true"
@logout="onLogout"
/>
</template>
<template v-if="showSidebar" #sidebar>
<CfSidebar
:items="sidebarItems"
:model-value="route"
:collapsed="sidebarCollapsed"
@update:modelValue="(key) => (route = key as RouteId)"
/>
</template>
<section class="adm-body">
<CfNavMenu
v-if="menuForm === 'topbar'"
:items="navMenuItems"
:active="route"
variant="underline"
class="adm-body__nav"
@navigate="(item) => (route = item.key as RouteId)"
/>
<CfBreadcrumb :items="breadcrumbItems" />
<h2 class="adm-body__title">{{ flatItem(route)?.label ?? '' }}</h2>
<component :is="pageComp" :key="route" />
</section>
</CfAppShell>
<SettingsDrawer v-model:open="settingsOpen" />
<ProfileModal v-model:open="profileOpen" />
<ChangePasswordModal v-model:open="passwordOpen" />
<CfCommandPalette
:open="paletteOpen"
:items="paletteItems"
:placeholder="t.cmd_placeholder"
:empty-text="t.cmd_empty"
@update:open="(v) => (paletteOpen = v)"
@select="onPaletteSelect"
/>
<CfTour
v-model="tourOpen"
:steps="tourSteps"
/>
</template>
</div>
</template>
<style scoped>
.adm-root {
display: flex;
flex-direction: column;
background: var(--bg-0);
color: var(--fg-1);
font-family: var(--font-sans);
height: 100%;
min-height: 720px;
isolation: isolate;
border-radius: var(--r-6);
overflow: hidden;
border: 1px solid var(--line-1);
}
.adm-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px 20px 24px;
}
.adm-body__title {
margin: 0;
font-size: var(--t-18);
font-weight: var(--w-medium);
color: var(--fg-1);
}
</style> <script setup lang="ts">
/**
* admin-mini · 顶部 header。
* 左:品牌;中:搜索;右:消息铃铛 / 语言 / 设置齿轮(→ 右抽屉)/ 用户头像(→ 下拉菜单)。
* 通过 emit 把交互上抛给 AdminMiniDemo 父组件统一处理。
*/
import { computed, inject, ref } from 'vue';
import {
CfSearchInput,
CfPopover,
CfDropdown,
CfAvatar,
CfBadge,
CfTag,
} from '@chufix-design/vue';
import type { DropdownItem } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from './state';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const emit = defineEmits<{
(e: 'open-settings'): void;
(e: 'open-profile'): void;
(e: 'open-change-password'): void;
(e: 'logout'): void;
}>();
const search = ref('');
// 通知 mock 数据,演示用
interface NotifItem { id: number; title: string; from: string; at: string; unread: boolean }
const notifications = ref<NotifItem[]>([
{ id: 1, title: '用户 ada 提交了新角色申请', from: 'system', at: '2 分钟前', unread: true },
{ id: 2, title: '操作日志:grace 导出了登录日志', from: 'audit', at: '8 分钟前', unread: true },
{ id: 3, title: '字典「日志级别」新增 DEBUG 项', from: 'admin', at: '32 分钟前', unread: false },
{ id: 4, title: '今日凌晨完成数据库自动备份', from: 'cron', at: '昨天', unread: false },
]);
const unreadCount = computed(() => notifications.value.filter((n) => n.unread).length);
function markAllRead() {
notifications.value = notifications.value.map((n) => ({ ...n, unread: false }));
}
const userMenuItems = computed<DropdownItem[]>(() => [
{ key: 'user-info', label: 'Admin · [email protected]', header: true },
{ key: 'divider-1', divider: true },
{ key: 'profile', label: t.value.profile },
{ key: 'change-password', label: t.value.change_password },
{ key: 'divider-2', divider: true },
{ key: 'logout', label: t.value.logout, tone: 'danger' },
]);
function onUserMenu(item: DropdownItem) {
switch (item.key) {
case 'profile': emit('open-profile'); break;
case 'change-password': emit('open-change-password'); break;
case 'logout': emit('logout'); break;
}
}
function toggleLocale() {
state.locale.value = state.locale.value === 'zh' ? 'en' : 'zh';
}
</script>
<template>
<div class="adm-header">
<div class="adm-header__brand" data-tour="brand">
<span class="adm-header__logo" />
<span class="adm-header__title">{{ t.brand }}</span>
<CfTag size="sm" tone="info" variant="soft">demo</CfTag>
</div>
<div class="adm-header__center" data-tour="search">
<CfSearchInput
v-model="search"
:placeholder="t.search_placeholder"
size="sm"
/>
</div>
<div class="adm-header__actions">
<CfPopover placement="bottom" :width="320">
<button class="adm-iconbtn" type="button" :aria-label="t.notifications">
<CfBadge :content="unreadCount" :max="99" :dot="false" :show-zero="false" placement="top-right">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8a6 6 0 1112 0c0 6 3 7 3 7H3s3-1 3-7" />
<path d="M10 19a2 2 0 004 0" />
</svg>
</CfBadge>
</button>
<template #content>
<div class="adm-notif">
<header class="adm-notif__head">
<span class="adm-notif__title">{{ t.notifications }}</span>
<button type="button" class="adm-notif__link" @click="markAllRead">
{{ t.mark_all_read }}
</button>
</header>
<ul v-if="notifications.length" class="adm-notif__list">
<li
v-for="n in notifications"
:key="n.id"
class="adm-notif__item"
:class="{ 'is-unread': n.unread }"
>
<span class="adm-notif__dot" />
<div class="adm-notif__body">
<div class="adm-notif__text">{{ n.title }}</div>
<div class="adm-notif__meta">{{ n.from }} · {{ n.at }}</div>
</div>
</li>
</ul>
<div v-else class="adm-notif__empty">{{ t.no_notifications }}</div>
<footer class="adm-notif__foot">
<a href="#" @click.prevent>{{ t.view_all }}</a>
</footer>
</div>
</template>
</CfPopover>
<button class="adm-iconbtn adm-iconbtn--lang" type="button" :aria-label="t.switch_locale" @click="toggleLocale">
{{ state.locale.value === 'zh' ? '中' : 'EN' }}
</button>
<button class="adm-iconbtn" type="button" :aria-label="t.settings" data-tour="settings" @click="emit('open-settings')">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09a1.65 1.65 0 00-1-1.51 1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09a1.65 1.65 0 001.51-1 1.65 1.65 0 00-.33-1.82l-.06-.06A2 2 0 017.04 4.04l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9c.36.94 1.18 1.5 2.15 1.51H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
</button>
<CfDropdown :items="userMenuItems" placement="bottom" :width="200" @select="onUserMenu">
<button class="adm-user" type="button" data-tour="user">
<CfAvatar name="Admin" size="sm" />
<span class="adm-user__name">Admin</span>
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6l4 4 4-4" />
</svg>
</button>
</CfDropdown>
</div>
</div>
</template>
<style scoped>
.adm-header {
display: flex;
align-items: center;
gap: 16px;
padding: 0 16px;
height: 100%;
width: 100%;
}
.adm-header__brand {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.adm-header__logo {
width: 22px;
height: 22px;
border-radius: var(--r-4);
background: linear-gradient(135deg, var(--accent-1), color-mix(in oklch, var(--accent-1), var(--bg-0) 35%));
box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--accent-1), transparent 50%);
}
.adm-header__title {
font-weight: var(--w-medium);
color: var(--fg-1);
white-space: nowrap;
}
.adm-header__center {
width: 320px;
max-width: 36%;
}
.adm-header__center :deep(.cf-searchinput) {
width: 100%;
}
.adm-header__actions {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.adm-iconbtn {
width: 32px;
height: 32px;
border-radius: var(--r-4);
border: 1px solid transparent;
background: transparent;
color: var(--fg-2);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.adm-iconbtn:hover { background: var(--bg-2); color: var(--fg-1); }
.adm-iconbtn--lang {
width: auto;
padding: 0 10px;
font-size: var(--t-12);
font-weight: var(--w-medium);
border: 1px solid var(--line-1);
}
.adm-user {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 8px 4px 4px;
border-radius: var(--r-pill);
border: 1px solid transparent;
background: transparent;
color: var(--fg-1);
cursor: pointer;
transition: background var(--dur-fast) var(--ease-out);
}
.adm-user:hover { background: var(--bg-2); }
.adm-user__name {
font-size: var(--t-12);
font-weight: var(--w-medium);
}
.adm-notif {
background: var(--bg-1);
border-radius: var(--r-4);
min-width: 280px;
}
.adm-notif__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--line-1);
}
.adm-notif__title {
font-weight: var(--w-medium);
color: var(--fg-1);
font-size: var(--t-13);
}
.adm-notif__link {
background: transparent;
border: 0;
color: var(--accent-1);
font-size: var(--t-11);
cursor: pointer;
padding: 0;
}
.adm-notif__list {
margin: 0;
padding: 0;
list-style: none;
max-height: 280px;
overflow: auto;
}
.adm-notif__item {
display: grid;
grid-template-columns: 16px 1fr;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--line-1);
}
.adm-notif__item:last-child { border-bottom: 0; }
.adm-notif__dot {
width: 6px;
height: 6px;
margin-top: 6px;
border-radius: 50%;
background: transparent;
border: 1px solid var(--line-2);
}
.adm-notif__item.is-unread .adm-notif__dot {
background: var(--accent-1);
border-color: var(--accent-1);
}
.adm-notif__text {
color: var(--fg-1);
font-size: var(--t-12);
line-height: 1.5;
}
.adm-notif__meta {
margin-top: 2px;
color: var(--fg-3);
font-size: var(--t-11);
}
.adm-notif__empty {
padding: 28px 16px;
text-align: center;
color: var(--fg-3);
font-size: var(--t-12);
}
.adm-notif__foot {
padding: 8px 12px;
text-align: center;
border-top: 1px solid var(--line-1);
}
.adm-notif__foot a {
color: var(--accent-1);
font-size: var(--t-12);
}
</style> <script setup lang="ts">
/**
* admin-mini 登录页 —— 演示态,无真正后端。
* 通过 admin / admin 通过校验后 emit login-success 给 AdminMiniDemo。
* 用 ChuFix 组件:CfInput / CfButton / CfCheckbox / CfAlert / CfTag。
*/
import { computed, inject, onMounted, ref } from 'vue';
import { CfInput, CfButton, CfCheckbox, CfAlert, CfTag } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from './state';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const emit = defineEmits<{
(e: 'login-success', payload: { username: string; remember: boolean }): void;
}>();
const username = ref('admin');
const password = ref('admin');
const remember = ref(true);
const loading = ref(false);
const error = ref('');
const usernameRef = ref<InstanceType<typeof CfInput> | null>(null);
onMounted(() => {
// 焦点交给账号输入框,更符合习惯
setTimeout(() => {
(usernameRef.value as unknown as { focus?: () => void } | null)?.focus?.();
}, 100);
});
function submit() {
error.value = '';
if (!username.value.trim() || !password.value) {
error.value = t.value.login_invalid;
return;
}
loading.value = true;
// 演示态:800ms 假等待,让 loading 状态肉眼可见
setTimeout(() => {
loading.value = false;
if (username.value.trim() === 'admin' && password.value === 'admin') {
emit('login-success', { username: username.value.trim(), remember: remember.value });
} else {
error.value = t.value.login_invalid;
}
}, 600);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Enter') submit();
}
</script>
<template>
<div class="adm-login" :data-theme="state.theme.value" :data-density="state.density.value">
<aside class="adm-login__brand">
<div class="adm-login__brand-card">
<div class="adm-login__logo" aria-hidden="true">
<svg viewBox="0 0 32 32" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 10 L16 4 L26 10 L26 22 L16 28 L6 22 Z" />
<path d="M11 13 L16 10 L21 13 L21 19 L16 22 L11 19 Z" opacity="0.6" />
</svg>
</div>
<h1 class="adm-login__title">{{ t.brand }}</h1>
<p class="adm-login__lede">{{ t.login_lede }}</p>
<ul class="adm-login__features">
<li>
<CfTag size="sm" tone="primary" variant="soft">Vue / React</CfTag>
<span>{{ state.locale.value === 'zh' ? '双框架同源' : 'Same source for both frameworks' }}</span>
</li>
<li>
<CfTag size="sm" tone="success" variant="soft">CRUD</CfTag>
<span>{{ state.locale.value === 'zh' ? '用户 / 角色 / 字典 / 菜单 全流程' : 'Users / roles / dictionary / menus end-to-end' }}</span>
</li>
<li>
<CfTag size="sm" tone="info" variant="soft">Theme</CfTag>
<span>{{ state.locale.value === 'zh' ? '三主题、两密度、五主色' : 'Three themes, two densities, five accents' }}</span>
</li>
</ul>
</div>
</aside>
<main class="adm-login__panel">
<div class="adm-login__form" @keydown="onKey">
<header class="adm-login__form-head">
<h2>{{ t.login_title }}</h2>
<p>{{ t.login_hint }}</p>
</header>
<CfAlert
v-if="error"
tone="danger"
variant="soft"
:title="error"
closable
@close="error = ''"
/>
<div class="adm-login__field">
<label class="adm-login__label">{{ t.login_username }}</label>
<CfInput
ref="usernameRef"
v-model="username"
:placeholder="t.login_username"
size="lg"
/>
</div>
<div class="adm-login__field">
<label class="adm-login__label">{{ t.login_password }}</label>
<CfInput
v-model="password"
type="password"
:placeholder="t.login_password"
size="lg"
/>
</div>
<div class="adm-login__row">
<label class="adm-login__check">
<CfCheckbox v-model="remember" />
<span>{{ t.login_remember }}</span>
</label>
<a class="adm-login__link" href="#" @click.prevent>
{{ state.locale.value === 'zh' ? '忘记密码' : 'Forgot password?' }}
</a>
</div>
<CfButton
variant="primary"
size="lg"
block
:loading="loading"
@click="submit"
>
{{ t.login_submit }}
</CfButton>
<p class="adm-login__demo">{{ t.login_demo_account }}</p>
</div>
</main>
</div>
</template>
<style scoped>
.adm-login {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 100%;
height: 100%;
background: var(--bg-0);
color: var(--fg-1);
font-family: var(--font-sans);
isolation: isolate;
}
.adm-login__brand {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background:
radial-gradient(circle at 80% 20%, color-mix(in oklch, var(--accent-1), transparent 70%), transparent 55%),
radial-gradient(circle at 10% 80%, color-mix(in oklch, var(--accent-1), transparent 80%), transparent 60%),
linear-gradient(135deg, color-mix(in oklch, var(--accent-1), var(--bg-0) 60%), var(--bg-0));
}
.adm-login__brand-card {
max-width: 360px;
display: flex;
flex-direction: column;
gap: 14px;
}
.adm-login__logo {
width: 40px;
height: 40px;
border-radius: var(--r-4);
background: linear-gradient(135deg, var(--accent-1), color-mix(in oklch, var(--accent-1), var(--bg-0) 40%));
box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent-1), transparent 50%) inset;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--fg-on-accent, #fff);
}
.adm-login__title {
margin: 0;
font-size: var(--t-22);
font-weight: var(--w-medium);
color: var(--fg-1);
}
.adm-login__lede {
margin: 0;
color: var(--fg-2);
font-size: var(--t-14);
line-height: 1.6;
}
.adm-login__features {
margin: 12px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.adm-login__features li {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--fg-2);
font-size: var(--t-12);
}
.adm-login__panel {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 32px;
}
.adm-login__form {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: 14px;
}
.adm-login__form-head h2 {
margin: 0 0 4px;
font-size: var(--t-18);
font-weight: var(--w-medium);
color: var(--fg-1);
}
.adm-login__form-head p {
margin: 0;
color: var(--fg-3);
font-size: var(--t-12);
}
.adm-login__field {
display: flex;
flex-direction: column;
gap: 6px;
}
.adm-login__field :deep(.cf-input) {
max-width: none;
width: 100%;
}
.adm-login__label {
font-size: var(--t-12);
color: var(--fg-2);
}
.adm-login__row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 2px;
}
.adm-login__check {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: var(--t-12);
color: var(--fg-2);
cursor: pointer;
}
.adm-login__link {
color: var(--accent-1);
font-size: var(--t-12);
text-decoration: none;
}
.adm-login__link:hover { text-decoration: none; opacity: 0.85; }
.adm-login__demo {
margin: 0;
text-align: center;
color: var(--fg-3);
font-size: var(--t-11);
padding-top: 10px;
border-top: 1px dashed var(--line-1);
}
@media (max-width: 820px) {
.adm-login {
grid-template-columns: 1fr;
}
.adm-login__brand {
display: none;
}
}
</style> <script setup lang="ts">
/**
* 右侧抽屉:主题 / 密度 / 菜单形态 / 主色 设置面板。
* 替代之前平铺在最顶上的 toolbar,让外壳更像真实后台。
*/
import { computed, inject } from 'vue';
import { CfDrawer, CfSegmentedControl } from '@chufix-design/vue';
import {
DemoStateKey,
STRINGS,
type DemoTheme,
type DemoDensity,
type DemoMenuForm,
type DemoAccent,
} from './state';
const state = inject(DemoStateKey)!;
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'update:open', value: boolean): void }>();
const t = computed(() => STRINGS[state.locale.value]);
const themeOpts = computed<{ value: DemoTheme; label: string }[]>(() => [
{ value: 'dark-cool', label: t.value.theme_dark_cool },
{ value: 'dark-warm', label: t.value.theme_dark_warm },
{ value: 'light', label: t.value.theme_light },
]);
const densityOpts = computed<{ value: DemoDensity; label: string }[]>(() => [
{ value: 'comfortable', label: t.value.density_comfortable },
{ value: 'compact', label: t.value.density_compact },
]);
const menuOpts = computed<{ value: DemoMenuForm; label: string }[]>(() => [
{ value: 'sidebar', label: t.value.menu_sidebar },
{ value: 'topbar', label: t.value.menu_topbar },
{ value: 'collapsed', label: t.value.menu_collapsed },
]);
const accentOpts: { value: DemoAccent; color: string }[] = [
{ value: 'blue', color: 'oklch(64% 0.16 263)' },
{ value: 'green', color: 'oklch(68% 0.16 150)' },
{ value: 'purple', color: 'oklch(64% 0.18 300)' },
{ value: 'orange', color: 'oklch(72% 0.16 60)' },
{ value: 'rose', color: 'oklch(66% 0.18 15)' },
];
</script>
<template>
<CfDrawer
:open="props.open"
placement="right"
size="sm"
:title="t.settings"
:show-close="true"
:mask="true"
@update:open="(v) => emit('update:open', v)"
>
<div class="adm-settings">
<section class="adm-settings__group">
<h4 class="adm-settings__label">{{ t.switch_theme }}</h4>
<CfSegmentedControl
:model-value="state.theme.value"
:items="themeOpts"
size="sm"
@update:modelValue="(v: string) => (state.theme.value = v as DemoTheme)"
/>
</section>
<section class="adm-settings__group">
<h4 class="adm-settings__label">{{ t.switch_density }}</h4>
<CfSegmentedControl
:model-value="state.density.value"
:items="densityOpts"
size="sm"
@update:modelValue="(v: string) => (state.density.value = v as DemoDensity)"
/>
</section>
<section class="adm-settings__group">
<h4 class="adm-settings__label">{{ t.switch_menu }}</h4>
<CfSegmentedControl
:model-value="state.menuForm.value"
:items="menuOpts"
size="sm"
@update:modelValue="(v: string) => (state.menuForm.value = v as DemoMenuForm)"
/>
</section>
<section class="adm-settings__group">
<h4 class="adm-settings__label">{{ t.switch_accent }}</h4>
<div class="adm-settings__accents">
<button
v-for="o in accentOpts"
:key="o.value"
type="button"
class="adm-settings__dot"
:class="{ 'is-active': state.accent.value === o.value }"
:style="{ background: o.color }"
:aria-label="o.value"
:aria-pressed="state.accent.value === o.value"
@click="state.accent.value = o.value"
/>
</div>
</section>
</div>
</CfDrawer>
</template>
<style scoped>
.adm-settings {
display: flex;
flex-direction: column;
gap: 18px;
padding: 4px;
}
.adm-settings__group {
display: flex;
flex-direction: column;
gap: 8px;
}
.adm-settings__label {
margin: 0;
font-size: var(--t-11);
font-weight: var(--w-medium);
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--fg-3);
}
.adm-settings__accents {
display: inline-flex;
gap: 8px;
}
.adm-settings__dot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid var(--line-1);
cursor: pointer;
transition: transform var(--dur-fast) var(--ease-out);
padding: 0;
}
.adm-settings__dot:hover { transform: scale(1.1); }
.adm-settings__dot.is-active {
outline: 2px solid var(--fg-1);
outline-offset: 2px;
}
</style> <script setup lang="ts">
/** 个人中心 modal:账号基本信息(演示态,本地保存)。 */
import { computed, inject, ref, watch } from 'vue';
import { CfModal, CfForm, CfFormField, CfInput, CfSelect, CfAvatar, toast } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from './state';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'update:open', value: boolean): void }>();
const form = ref({
username: 'admin',
name: '系统管理员',
email: '[email protected]',
phone: '13800000001',
role: '超级管理员',
});
// 每次开启时重置(演示用,无后端持久化)
watch(() => props.open, (v) => {
if (v) {
form.value = {
username: 'admin',
name: '系统管理员',
email: '[email protected]',
phone: '13800000001',
role: '超级管理员',
};
}
});
function save() {
toast.success(t.value.profile_saved);
emit('update:open', false);
}
</script>
<template>
<CfModal
:open="props.open"
:title="t.profile"
size="md"
:ok-text="t.save"
:cancel-text="t.cancel"
:on-before-ok="() => { save(); return true; }"
@update:open="(v) => emit('update:open', v)"
>
<div class="adm-profile">
<div class="adm-profile__avatar">
<CfAvatar name="Admin" size="xl" />
<div class="adm-profile__name">
<strong>{{ form.name }}</strong>
<span>{{ form.username }} · {{ form.role }}</span>
</div>
</div>
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.col_name" name="name">
<CfInput v-model="form.name" />
</CfFormField>
<CfFormField :label="t.col_email" name="email">
<CfInput v-model="form.email" type="email" />
</CfFormField>
<CfFormField :label="t.col_phone" name="phone">
<CfInput v-model="form.phone" />
</CfFormField>
</CfForm>
</div>
</CfModal>
</template>
<style scoped>
.adm-profile { display: flex; flex-direction: column; gap: 16px; }
.adm-profile__avatar {
display: flex;
align-items: center;
gap: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line-1);
}
.adm-profile__name { display: flex; flex-direction: column; gap: 2px; }
.adm-profile__name strong { color: var(--fg-1); font-size: var(--t-14); }
.adm-profile__name span { color: var(--fg-3); font-size: var(--t-12); }
</style> <script setup lang="ts">
/** 修改密码 modal:演示态,本地校验,不接后端。 */
import { computed, inject, ref, watch } from 'vue';
import { CfModal, CfForm, CfFormField, CfInput, CfPasswordStrength, toast } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from './state';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'update:open', value: boolean): void }>();
const form = ref({ current: '', next: '', confirm: '' });
watch(() => props.open, (v) => {
if (v) form.value = { current: '', next: '', confirm: '' };
});
function submit(): boolean {
if (!form.value.current || !form.value.next || !form.value.confirm) {
toast.error(state.locale.value === 'zh' ? '请填写完整' : 'Please fill in all fields');
return false;
}
if (form.value.next !== form.value.confirm) {
toast.error(t.value.password_mismatch);
return false;
}
toast.success(t.value.password_changed);
return true;
}
</script>
<template>
<CfModal
:open="props.open"
:title="t.change_password"
size="sm"
:ok-text="t.save"
:cancel-text="t.cancel"
:on-before-ok="submit"
@update:open="(v) => emit('update:open', v)"
>
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.current_password" name="current">
<CfInput v-model="form.current" type="password" />
</CfFormField>
<CfFormField :label="t.new_password" name="next">
<CfInput v-model="form.next" type="password" />
<CfPasswordStrength v-if="form.next" :value="form.next" size="sm" style="margin-top: 6px;" />
</CfFormField>
<CfFormField :label="t.confirm_password" name="confirm">
<CfInput v-model="form.confirm" type="password" />
</CfFormField>
</CfForm>
</CfModal>
</template> // admin-mini 演示的全局 UI 状态:主题 / 密度 / 菜单形态 / accent 色 / 语言 / 源码面板。
// 用 inject/provide 把 reactive 实例下发到所有 page,page 内部按需读取以渲染 i18n 文案。
import { type InjectionKey, type Ref } from 'vue';
export type DemoTheme = 'dark-cool' | 'dark-warm' | 'light';
export type DemoDensity = 'comfortable' | 'compact';
export type DemoMenuForm = 'sidebar' | 'topbar' | 'collapsed';
export type DemoAccent = 'blue' | 'green' | 'purple' | 'orange' | 'rose';
export type DemoLocale = 'zh' | 'en';
export interface DemoState {
theme: Ref<DemoTheme>;
density: Ref<DemoDensity>;
menuForm: Ref<DemoMenuForm>;
accent: Ref<DemoAccent>;
locale: Ref<DemoLocale>;
}
export const DemoStateKey: InjectionKey<DemoState> = Symbol('AdminMiniDemoState');
export const ACCENT_HUE: Record<DemoAccent, string> = {
blue: 'oklch(64% 0.16 263)',
green: 'oklch(68% 0.16 150)',
purple: 'oklch(64% 0.18 300)',
orange: 'oklch(72% 0.16 60)',
rose: 'oklch(66% 0.18 15)',
};
export interface DemoStrings {
// top toolbar
brand: string;
switch_theme: string;
switch_density: string;
switch_menu: string;
switch_accent: string;
switch_locale: string;
settings: string;
notifications: string;
no_notifications: string;
mark_all_read: string;
view_all: string;
profile: string;
change_password: string;
logout: string;
search_placeholder: string;
current_password: string;
new_password: string;
confirm_password: string;
password_mismatch: string;
password_changed: string;
profile_saved: string;
logout_done: string;
theme_dark_cool: string;
theme_dark_warm: string;
theme_light: string;
density_comfortable: string;
density_compact: string;
menu_sidebar: string;
menu_topbar: string;
menu_collapsed: string;
// pages titles
page_dashboard: string;
page_users: string;
page_roles: string;
page_user_roles: string;
page_dict: string;
page_op_log: string;
page_login_log: string;
login_title: string;
login_lede: string;
login_username: string;
login_password: string;
login_remember: string;
login_submit: string;
login_hint: string;
login_demo_account: string;
login_invalid: string;
login_welcome: string;
page_menus: string;
menu_title: string;
menu_icon: string;
menu_route: string;
menu_sort: string;
menu_visible: string;
menu_parent: string;
menu_no_parent: string;
menu_root_only: string;
menu_add_child: string;
menu_select_to_edit: string;
menu_hint: string;
page_org: string;
col_dept: string;
org_total: string;
org_select_hint: string;
cmd_placeholder: string;
cmd_empty: string;
cmd_navigate: string;
cmd_actions: string;
cmd_open_settings: string;
cmd_open_profile: string;
cmd_change_password: string;
cmd_logout: string;
tour_brand_title: string;
tour_brand_desc: string;
tour_search_title: string;
tour_search_desc: string;
tour_settings_title: string;
tour_settings_desc: string;
tour_user_title: string;
tour_user_desc: string;
tour_next: string;
tour_finish: string;
tour_skip: string;
page_sys_settings: string;
sys_general: string;
sys_security: string;
sys_backup: string;
sys_log_retention: string;
sys_log_retention_hint: string;
sys_two_factor: string;
sys_two_factor_hint: string;
sys_password_policy: string;
sys_password_policy_basic: string;
sys_password_policy_strict: string;
sys_password_policy_paranoid: string;
sys_session_timeout: string;
sys_next_backup: string;
sys_upload_logs: string;
sys_save_changes: string;
sys_revert: string;
sys_saved: string;
sys_reverted: string;
// groups
grp_overview: string;
grp_authz: string;
grp_system: string;
// common
search: string;
create: string;
edit: string;
delete: string;
save: string;
cancel: string;
confirm: string;
status: string;
status_active: string;
status_disabled: string;
status_ok: string;
status_fail: string;
status_success: string;
status_failed: string;
actions: string;
total_rows: string;
// table columns
col_id: string;
col_username: string;
col_name: string;
col_email: string;
col_phone: string;
col_created_at: string;
col_role_name: string;
col_role_desc: string;
col_role_perms: string;
perm_user_read: string;
perm_user_write: string;
perm_role_read: string;
perm_role_write: string;
perm_dict_read: string;
perm_dict_write: string;
perm_log_read: string;
perm_log_export: string;
col_user_roles: string;
col_assign: string;
col_dict_label: string;
col_dict_value: string;
col_dict_remark: string;
col_log_user: string;
col_log_action: string;
col_log_resource: string;
col_log_ip: string;
col_log_at: string;
col_log_ua: string;
column_settings: string;
reset_columns: string;
phone_updated: string;
// dashboard
kpi_users: string;
kpi_roles: string;
kpi_login_today: string;
kpi_op_today: string;
recent_op: string;
recent_login: string;
}
export const STRINGS: Record<DemoLocale, DemoStrings> = {
zh: {
brand: 'admin-mini 后台',
switch_theme: '主题',
switch_density: '密度',
switch_menu: '菜单形态',
switch_accent: '主色',
switch_locale: '语言',
settings: '主题设置',
notifications: '通知',
no_notifications: '暂无新通知',
mark_all_read: '全部已读',
view_all: '查看全部',
profile: '个人中心',
change_password: '修改密码',
logout: '退出登录',
search_placeholder: '搜索菜单 / 用户 / 角色…',
current_password: '当前密码',
new_password: '新密码',
confirm_password: '确认新密码',
password_mismatch: '两次输入的新密码不一致',
password_changed: '密码已更新',
profile_saved: '资料已保存',
logout_done: '已退出(演示态,无实际登出)',
theme_dark_cool: '深蓝',
theme_dark_warm: '深棕',
theme_light: '浅色',
density_comfortable: '宽松',
density_compact: '紧凑',
menu_sidebar: '侧栏',
menu_topbar: '顶栏',
menu_collapsed: '折叠侧栏',
page_dashboard: '工作台',
page_users: '用户管理',
page_roles: '角色管理',
page_user_roles: '用户角色',
page_dict: '字典管理',
page_op_log: '操作日志',
page_login_log: '登录日志',
login_title: 'admin-mini 后台登录',
login_lede: '使用 ChuFix UI 组件搭建的演示后台',
login_username: '账号',
login_password: '密码',
login_remember: '记住我',
login_submit: '登录',
login_hint: '演示账号已自动填入',
login_demo_account: '演示账号:admin / admin',
login_invalid: '账号或密码错误(演示用 admin / admin)',
login_welcome: '欢迎回来,{user}',
page_menus: '菜单管理',
menu_title: '名称',
menu_icon: '图标',
menu_route: '路由',
menu_sort: '排序',
menu_visible: '显示',
menu_parent: '上级菜单',
menu_no_parent: '(无上级 / 一级菜单)',
menu_root_only: '只有一级菜单可以新增子项。',
menu_add_child: '+ 新增子菜单',
menu_select_to_edit: '请选中左侧菜单后在此编辑。',
menu_hint: '左侧树展示完整菜单层级,点击节点可在右侧编辑名称、图标、路由与排序。',
page_org: '组织架构',
col_dept: '所属部门',
org_total: '共 {n} 名成员',
org_select_hint: '点击左侧部门可筛选右侧成员,根节点显示全部。',
cmd_placeholder: '输入命令、页面或动作…',
cmd_empty: '没有匹配项',
cmd_navigate: '导航',
cmd_actions: '快捷动作',
cmd_open_settings: '打开主题设置抽屉',
cmd_open_profile: '打开个人中心',
cmd_change_password: '修改密码',
cmd_logout: '退出登录',
tour_brand_title: '欢迎来到 admin-mini',
tour_brand_desc: '这是一个完全由 ChuFix UI 组件组成的真实后台演示。',
tour_search_title: '快捷搜索',
tour_search_desc: '试试键盘上的 Ctrl + K 打开全局命令面板,跨页面跳转。',
tour_settings_title: '主题 / 密度 / 菜单形态',
tour_settings_desc: '点击齿轮可在右侧抽屉里切换主题、密度、菜单展示形态和主色。',
tour_user_title: '个人中心 + 修改密码',
tour_user_desc: '点击头像下拉,可以打开个人中心、修改密码或退出登录。',
tour_next: '下一步',
tour_finish: '完成',
tour_skip: '跳过',
page_sys_settings: '系统设置',
sys_general: '常规',
sys_security: '安全',
sys_backup: '备份',
sys_log_retention: '日志保留天数',
sys_log_retention_hint: '超过该天数的日志会自动归档',
sys_two_factor: '启用两步验证',
sys_two_factor_hint: '管理员账号强制开启',
sys_password_policy: '密码策略',
sys_password_policy_basic: '基本(≥ 8 位)',
sys_password_policy_strict: '严格(≥ 12 位 + 大小写 + 数字)',
sys_password_policy_paranoid: '偏执(≥ 16 位 + 大小写 + 数字 + 符号)',
sys_session_timeout: '会话超时(分钟)',
sys_next_backup: '下一次备份时间',
sys_upload_logs: '上传归档日志',
sys_save_changes: '保存设置',
sys_revert: '撤销',
sys_saved: '设置已保存',
sys_reverted: '已撤销',
grp_overview: '概览',
grp_authz: '权限',
grp_system: '系统',
search: '搜索',
create: '新增',
edit: '编辑',
delete: '删除',
save: '保存',
cancel: '取消',
confirm: '确定',
status: '状态',
status_active: '启用',
status_disabled: '停用',
status_ok: '成功',
status_fail: '失败',
status_success: '成功',
status_failed: '失败',
actions: '操作',
total_rows: '共 {n} 条',
col_id: 'ID',
col_username: '账号',
col_name: '姓名',
col_email: '邮箱',
col_phone: '手机号',
col_created_at: '创建时间',
col_role_name: '角色名',
col_role_desc: '描述',
col_role_perms: '权限',
perm_user_read: '查看用户',
perm_user_write: '编辑用户',
perm_role_read: '查看角色',
perm_role_write: '编辑角色',
perm_dict_read: '查看字典',
perm_dict_write: '编辑字典',
perm_log_read: '查看日志',
perm_log_export: '导出日志',
col_user_roles: '已分配角色',
col_assign: '分配角色',
col_dict_label: '名称',
col_dict_value: '值',
col_dict_remark: '备注',
col_log_user: '操作人',
col_log_action: '动作',
col_log_resource: '资源',
col_log_ip: 'IP',
col_log_at: '时间',
col_log_ua: '设备',
column_settings: '列设置',
reset_columns: '重置列',
phone_updated: '手机号已更新',
kpi_users: '用户总数',
kpi_roles: '角色总数',
kpi_login_today: '今日登录',
kpi_op_today: '今日操作',
recent_op: '最近操作',
recent_login: '最近登录',
},
en: {
brand: 'admin-mini console',
switch_theme: 'Theme',
switch_density: 'Density',
switch_menu: 'Menu',
switch_accent: 'Accent',
switch_locale: 'Locale',
settings: 'Appearance',
notifications: 'Notifications',
no_notifications: 'You are all caught up',
mark_all_read: 'Mark all read',
view_all: 'View all',
profile: 'Profile',
change_password: 'Change password',
logout: 'Sign out',
search_placeholder: 'Search menus / users / roles…',
current_password: 'Current password',
new_password: 'New password',
confirm_password: 'Confirm new password',
password_mismatch: 'New passwords do not match',
password_changed: 'Password updated',
profile_saved: 'Profile saved',
logout_done: 'Signed out (demo only — no real session)',
theme_dark_cool: 'Dark cool',
theme_dark_warm: 'Dark warm',
theme_light: 'Light',
density_comfortable: 'Comfortable',
density_compact: 'Compact',
menu_sidebar: 'Sidebar',
menu_topbar: 'Topbar',
menu_collapsed: 'Collapsed',
page_dashboard: 'Dashboard',
page_users: 'Users',
page_roles: 'Roles',
page_user_roles: 'User roles',
page_dict: 'Dictionary',
page_op_log: 'Operation log',
page_login_log: 'Login log',
login_title: 'Sign in to admin-mini',
login_lede: 'Demo console powered by ChuFix UI components',
login_username: 'Username',
login_password: 'Password',
login_remember: 'Remember me',
login_submit: 'Sign in',
login_hint: 'Demo credentials are pre-filled',
login_demo_account: 'Demo: admin / admin',
login_invalid: 'Wrong username or password (use admin / admin)',
login_welcome: 'Welcome back, {user}',
page_menus: 'Menu management',
menu_title: 'Title',
menu_icon: 'Icon',
menu_route: 'Route',
menu_sort: 'Sort',
menu_visible: 'Visible',
menu_parent: 'Parent',
menu_no_parent: '(no parent / top-level)',
menu_root_only: 'Only top-level entries accept children.',
menu_add_child: '+ New child',
menu_select_to_edit: 'Select a node on the left to edit it.',
menu_hint: 'The tree on the left shows the menu hierarchy. Click any node to edit its title, icon, route and sort weight on the right.',
page_org: 'Organization',
col_dept: 'Department',
org_total: '{n} members',
org_select_hint: 'Pick a department on the left to filter members on the right. The root shows all.',
cmd_placeholder: 'Type a command, page or action…',
cmd_empty: 'No matches',
cmd_navigate: 'Navigate',
cmd_actions: 'Quick actions',
cmd_open_settings: 'Open appearance drawer',
cmd_open_profile: 'Open profile',
cmd_change_password: 'Change password',
cmd_logout: 'Sign out',
tour_brand_title: 'Welcome to admin-mini',
tour_brand_desc: 'A live admin demo built entirely with ChuFix UI components.',
tour_search_title: 'Quick search',
tour_search_desc: 'Press Ctrl + K anywhere to open the global command palette.',
tour_settings_title: 'Theme / density / menu',
tour_settings_desc: 'The gear opens a right drawer for theme, density, menu form and accent.',
tour_user_title: 'Profile + change password',
tour_user_desc: 'The avatar drops down to Profile, Change password and Sign out.',
tour_next: 'Next',
tour_finish: 'Finish',
tour_skip: 'Skip',
page_sys_settings: 'Settings',
sys_general: 'General',
sys_security: 'Security',
sys_backup: 'Backup',
sys_log_retention: 'Log retention (days)',
sys_log_retention_hint: 'Older logs are archived automatically',
sys_two_factor: 'Enable 2FA',
sys_two_factor_hint: 'Required for admin accounts',
sys_password_policy: 'Password policy',
sys_password_policy_basic: 'Basic (≥ 8 chars)',
sys_password_policy_strict: 'Strict (≥ 12 + upper/lower + digits)',
sys_password_policy_paranoid: 'Paranoid (≥ 16 + upper/lower + digits + symbols)',
sys_session_timeout: 'Session timeout (min)',
sys_next_backup: 'Next backup at',
sys_upload_logs: 'Upload archived logs',
sys_save_changes: 'Save settings',
sys_revert: 'Revert',
sys_saved: 'Settings saved',
sys_reverted: 'Reverted',
grp_overview: 'Overview',
grp_authz: 'Authorization',
grp_system: 'System',
search: 'Search',
create: 'Create',
edit: 'Edit',
delete: 'Delete',
save: 'Save',
cancel: 'Cancel',
confirm: 'Confirm',
status: 'Status',
status_active: 'Active',
status_disabled: 'Disabled',
status_ok: 'OK',
status_fail: 'Failed',
status_success: 'Success',
status_failed: 'Failed',
actions: 'Actions',
total_rows: '{n} rows',
col_id: 'ID',
col_username: 'Username',
col_name: 'Name',
col_email: 'Email',
col_phone: 'Phone',
col_created_at: 'Created at',
col_role_name: 'Role',
col_role_desc: 'Description',
col_role_perms: 'Permissions',
perm_user_read: 'Read users',
perm_user_write: 'Edit users',
perm_role_read: 'Read roles',
perm_role_write: 'Edit roles',
perm_dict_read: 'Read dictionary',
perm_dict_write: 'Edit dictionary',
perm_log_read: 'Read logs',
perm_log_export: 'Export logs',
col_user_roles: 'Assigned roles',
col_assign: 'Assign',
col_dict_label: 'Label',
col_dict_value: 'Value',
col_dict_remark: 'Remark',
col_log_user: 'User',
col_log_action: 'Action',
col_log_resource: 'Resource',
col_log_ip: 'IP',
col_log_at: 'Time',
col_log_ua: 'Device',
column_settings: 'Columns',
reset_columns: 'Reset columns',
phone_updated: 'Phone updated',
kpi_users: 'Total users',
kpi_roles: 'Total roles',
kpi_login_today: "Today's logins",
kpi_op_today: "Today's operations",
recent_op: 'Recent operations',
recent_login: 'Recent logins',
},
}; // admin-mini 演示用的内存 mock 数据,不接任何后端。
// 各 page 直接 import 这里的常量;增删改只动各自页面持有的 ref。
export interface AdminUser {
id: number;
username: string;
name: string;
email: string;
phone: string;
status: 'active' | 'disabled';
createdAt: string;
/** 所属部门 key(用于组织架构页 join)。 */
deptKey?: string;
}
export interface Department {
key: string;
label: string;
children?: Department[];
}
export interface AdminRole {
id: string;
name: string;
description: string;
permissions: string[];
}
export interface UserRoleLink {
userId: number;
roleIds: string[];
}
export interface DictItem {
id: string;
parentId: string | null;
label: string;
value: string;
remark: string;
}
export interface OperationLog {
id: number;
user: string;
action: string;
resource: string;
ip: string;
at: string;
status: 'ok' | 'fail';
}
export interface LoginLog {
id: number;
user: string;
ip: string;
ua: string;
at: string;
status: 'success' | 'failed';
}
export interface MenuEntry {
id: string;
parentId: string | null;
title: string;
icon?: string; // CfIcon name
route?: string;
sort: number;
visible: boolean;
}
export const initialMenus: MenuEntry[] = [
{ id: 'm-dashboard', parentId: null, title: '工作台', icon: 'layout-dashboard', route: '/dashboard', sort: 1, visible: true },
{ id: 'm-authz', parentId: null, title: '权限', icon: 'shield', route: '', sort: 2, visible: true },
{ id: 'm-users', parentId: 'm-authz', title: '用户管理', icon: 'users', route: '/users', sort: 1, visible: true },
{ id: 'm-roles', parentId: 'm-authz', title: '角色管理', icon: 'shield-check', route: '/roles', sort: 2, visible: true },
{ id: 'm-user-roles',parentId: 'm-authz', title: '用户角色', icon: 'user-cog', route: '/user-roles', sort: 3, visible: true },
{ id: 'm-org', parentId: 'm-authz', title: '组织架构', icon: 'building-2', route: '/org', sort: 4, visible: true },
{ id: 'm-system', parentId: null, title: '系统', icon: 'settings', route: '', sort: 3, visible: true },
{ id: 'm-dict', parentId: 'm-system', title: '字典管理', icon: 'book-text', route: '/dict', sort: 1, visible: true },
{ id: 'm-op-log', parentId: 'm-system', title: '操作日志', icon: 'file-text', route: '/op-log', sort: 2, visible: true },
{ id: 'm-login-log', parentId: 'm-system', title: '登录日志', icon: 'log-in', route: '/login-log', sort: 3, visible: true },
{ id: 'm-menus', parentId: 'm-system', title: '菜单管理', icon: 'list-tree', route: '/menus', sort: 4, visible: true },
{ id: 'm-settings', parentId: 'm-system', title: '系统设置', icon: 'sliders', route: '/settings', sort: 5, visible: true },
];
export const initialUsers: AdminUser[] = [
{ id: 1, username: 'admin', name: '系统管理员', email: '[email protected]', phone: '13800000001', status: 'active', createdAt: '2025-09-01 10:21', deptKey: 'd-root' },
{ id: 2, username: 'ada', name: 'Ada Lovelace', email: '[email protected]', phone: '13800000002', status: 'active', createdAt: '2025-10-12 14:03', deptKey: 'd-rnd-frontend' },
{ id: 3, username: 'linus', name: 'Linus Torvalds', email: '[email protected]', phone: '13800000003', status: 'active', createdAt: '2025-11-03 09:47', deptKey: 'd-rnd-backend' },
{ id: 4, username: 'grace', name: 'Grace Hopper', email: '[email protected]', phone: '13800000004', status: 'disabled', createdAt: '2025-11-19 16:55', deptKey: 'd-ops' },
{ id: 5, username: 'alan', name: 'Alan Turing', email: '[email protected]', phone: '13800000005', status: 'active', createdAt: '2026-01-08 11:32', deptKey: 'd-rnd-backend' },
{ id: 6, username: 'donald',name: 'Donald Knuth', email: '[email protected]',phone: '13800000006', status: 'active', createdAt: '2026-02-14 13:18', deptKey: 'd-rnd-frontend' },
];
export const initialDepartments: Department[] = [
{
key: 'd-root',
label: 'ChuFix Inc.',
children: [
{
key: 'd-rnd',
label: '研发中心',
children: [
{ key: 'd-rnd-frontend', label: '前端组' },
{ key: 'd-rnd-backend', label: '后端组' },
{ key: 'd-rnd-qa', label: '测试组' },
],
},
{
key: 'd-ops',
label: '运维与基础设施',
children: [
{ key: 'd-ops-sre', label: 'SRE' },
{ key: 'd-ops-dba', label: 'DBA' },
],
},
{ key: 'd-design', label: '设计中心' },
{ key: 'd-support', label: '客户支持' },
],
},
];
/** 收集某节点及其所有后代部门 key。 */
export function collectDeptKeys(dept: Department): string[] {
const out: string[] = [dept.key];
const visit = (d: Department) => {
if (!d.children) return;
for (const c of d.children) {
out.push(c.key);
visit(c);
}
};
visit(dept);
return out;
}
export function findDepartment(key: string, list: Department[] = initialDepartments): Department | null {
for (const d of list) {
if (d.key === key) return d;
if (d.children) {
const found = findDepartment(key, d.children);
if (found) return found;
}
}
return null;
}
export const initialRoles: AdminRole[] = [
{ id: 'r-admin', name: '超级管理员', description: '拥有全部权限', permissions: ['user:*', 'role:*', 'dict:*', 'log:*'] },
{ id: 'r-manager', name: '业务管理员', description: '业务模块全部读写', permissions: ['user:read', 'user:write', 'role:read'] },
{ id: 'r-viewer', name: '只读账号', description: '只能查看,不能修改', permissions: ['user:read', 'role:read', 'log:read'] },
{ id: 'r-auditor', name: '审计员', description: '查看日志,不能修改业务', permissions: ['log:read', 'log:export'] },
];
export const initialUserRoles: UserRoleLink[] = [
{ userId: 1, roleIds: ['r-admin'] },
{ userId: 2, roleIds: ['r-manager'] },
{ userId: 3, roleIds: ['r-manager', 'r-auditor'] },
{ userId: 4, roleIds: ['r-viewer'] },
{ userId: 5, roleIds: ['r-viewer'] },
{ userId: 6, roleIds: ['r-auditor'] },
];
export const initialDict: DictItem[] = [
{ id: 'd-status', parentId: null, label: '用户状态', value: 'user_status', remark: '系统内置' },
{ id: 'd-status-1', parentId: 'd-status', label: '启用', value: 'active', remark: '' },
{ id: 'd-status-2', parentId: 'd-status', label: '停用', value: 'disabled', remark: '' },
{ id: 'd-gender', parentId: null, label: '性别', value: 'gender', remark: '' },
{ id: 'd-gender-1', parentId: 'd-gender', label: '男', value: 'M', remark: '' },
{ id: 'd-gender-2', parentId: 'd-gender', label: '女', value: 'F', remark: '' },
{ id: 'd-gender-3', parentId: 'd-gender', label: '保密', value: 'X', remark: '' },
{ id: 'd-level', parentId: null, label: '日志级别', value: 'log_level', remark: '' },
{ id: 'd-level-1', parentId: 'd-level', label: 'INFO', value: 'info', remark: '' },
{ id: 'd-level-2', parentId: 'd-level', label: 'WARN', value: 'warn', remark: '' },
{ id: 'd-level-3', parentId: 'd-level', label: 'ERROR', value: 'error', remark: '' },
];
export const initialOpLogs: OperationLog[] = [
{ id: 1, user: 'admin', action: 'create', resource: '/api/users', ip: '10.1.0.21', at: '2026-05-12 08:21:03', status: 'ok' },
{ id: 2, user: 'ada', action: 'update', resource: '/api/roles/r-manager', ip: '10.1.0.22', at: '2026-05-12 08:19:51', status: 'ok' },
{ id: 3, user: 'linus', action: 'delete', resource: '/api/users/9', ip: '10.1.0.23', at: '2026-05-12 08:17:22', status: 'fail' },
{ id: 4, user: 'ada', action: 'login', resource: '/auth/login', ip: '10.1.0.22', at: '2026-05-12 08:14:08', status: 'ok' },
{ id: 5, user: 'grace', action: 'export', resource: '/api/logs/op', ip: '10.1.0.42', at: '2026-05-12 08:10:42', status: 'ok' },
{ id: 6, user: 'admin', action: 'update', resource: '/api/dict/d-status', ip: '10.1.0.21', at: '2026-05-12 08:02:13', status: 'ok' },
];
export const initialLoginLogs: LoginLog[] = [
{ id: 1, user: 'admin', ip: '10.1.0.21', ua: 'Chrome 124 / macOS', at: '2026-05-12 08:14:08', status: 'success' },
{ id: 2, user: 'ada', ip: '10.1.0.22', ua: 'Edge 123 / Windows', at: '2026-05-12 08:11:55', status: 'success' },
{ id: 3, user: 'linus', ip: '10.1.0.23', ua: 'Firefox 124 / Linux', at: '2026-05-12 08:09:31', status: 'success' },
{ id: 4, user: 'grace', ip: '203.0.113.6', ua: 'Safari 17 / iOS', at: '2026-05-12 08:07:18', status: 'failed' },
{ id: 5, user: 'grace', ip: '203.0.113.6', ua: 'Safari 17 / iOS', at: '2026-05-12 08:06:42', status: 'failed' },
{ id: 6, user: 'alan', ip: '10.1.0.24', ua: 'Chrome 124 / Linux', at: '2026-05-12 07:58:11', status: 'success' },
]; <script setup lang="ts">
/**
* 工作台(Dashboard)—— 概览大屏:
* - 4 张 CfMetricCard 含趋势 sparkline + delta
* - CfLineChart 七日登录 / 操作趋势
* - CfDonutChart 操作类型占比
* - 最近操作 + 最近登录 两张 CfTable
*/
import { computed, inject } from 'vue';
import {
CfMetricCard,
CfLineChart,
CfDonutChart,
CfTable,
CfTag,
} from '@chufix-design/vue';
import type { TableColumn } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import {
initialUsers,
initialRoles,
initialOpLogs,
initialLoginLogs,
type OperationLog,
type LoginLog,
} from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const kpis = computed(() => [
{
label: t.value.kpi_users,
value: initialUsers.length,
delta: 33,
trend: [3, 4, 4, 5, 5, 6, 6],
},
{
label: t.value.kpi_roles,
value: initialRoles.length,
delta: 25,
trend: [3, 3, 3, 4, 4, 4, 4],
},
{
label: t.value.kpi_login_today,
value: 38,
delta: 12,
trend: [22, 28, 24, 30, 35, 33, 38],
},
{
label: t.value.kpi_op_today,
value: 142,
delta: -4,
trend: [160, 152, 148, 138, 142, 150, 142],
},
]);
const trendDays = computed(() =>
state.locale.value === 'zh'
? ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
);
const trendSeries = computed(() => [
{ name: state.locale.value === 'zh' ? '登录数' : 'Logins', data: [22, 28, 24, 30, 35, 33, 38] },
{ name: state.locale.value === 'zh' ? '操作数' : 'Operations', data: [120, 138, 150, 142, 158, 160, 142] },
]);
const donutSegments = computed(() => {
const counts: Record<string, number> = {};
for (const r of initialOpLogs) counts[r.action] = (counts[r.action] ?? 0) + 1;
return Object.entries(counts).map(([name, value], i) => ({
name,
value,
colorIndex: i,
}));
});
const donutTotal = computed(() => initialOpLogs.length);
const opCols = computed<TableColumn<OperationLog>[]>(() => [
{ key: 'user', title: t.value.col_log_user, dataIndex: 'user', width: 100 },
{ key: 'action', title: t.value.col_log_action, dataIndex: 'action', width: 90 },
{ key: 'resource', title: t.value.col_log_resource, dataIndex: 'resource', ellipsis: true },
{ key: 'at', title: t.value.col_log_at, dataIndex: 'at', width: 180 },
]);
const loginCols = computed<TableColumn<LoginLog>[]>(() => [
{ key: 'user', title: t.value.col_log_user, dataIndex: 'user', width: 100 },
{ key: 'ip', title: t.value.col_log_ip, dataIndex: 'ip', width: 130 },
{ key: 'ua', title: t.value.col_log_ua, dataIndex: 'ua', ellipsis: true },
{ key: 'at', title: t.value.col_log_at, dataIndex: 'at', width: 180 },
]);
const opRows = computed<OperationLog[]>(() => initialOpLogs.slice(0, 5));
const loginRows = computed<LoginLog[]>(() => initialLoginLogs.slice(0, 5));
</script>
<template>
<div class="adm-dashboard">
<div class="adm-dashboard__kpis">
<CfMetricCard
v-for="k in kpis"
:key="k.label"
:label="k.label"
:value="k.value"
:delta="k.delta"
:trend="k.trend"
/>
</div>
<div class="adm-dashboard__charts">
<section class="adm-card">
<header class="adm-card__head">
<h3>{{ state.locale.value === 'zh' ? '近 7 天趋势' : '7-day trend' }}</h3>
<CfTag size="sm" tone="info">trend</CfTag>
</header>
<div class="adm-card__body">
<CfLineChart
:series="trendSeries"
:labels="trendDays"
:height="200"
:show-grid="true"
:show-labels="true"
:show-legend="true"
:show-tooltip="true"
smooth
/>
</div>
</section>
<section class="adm-card">
<header class="adm-card__head">
<h3>{{ state.locale.value === 'zh' ? '操作类型占比' : 'Operation breakdown' }}</h3>
<CfTag size="sm" tone="success">share</CfTag>
</header>
<div class="adm-card__body adm-card__body--donut">
<CfDonutChart
:segments="donutSegments"
:size="180"
:thickness="22"
:show-legend="true"
:center-label="state.locale.value === 'zh' ? '总数' : 'Total'"
:center-value="donutTotal"
/>
</div>
</section>
</div>
<div class="adm-dashboard__pair">
<section class="adm-card">
<header class="adm-card__head">
<h3>{{ t.recent_op }}</h3>
<CfTag size="sm" tone="info">live</CfTag>
</header>
<CfTable :columns="opCols" :rows="opRows" :row-key="(r: OperationLog) => String(r.id)" size="sm" />
</section>
<section class="adm-card">
<header class="adm-card__head">
<h3>{{ t.recent_login }}</h3>
<CfTag size="sm" tone="success">stable</CfTag>
</header>
<CfTable :columns="loginCols" :rows="loginRows" :row-key="(r: LoginLog) => String(r.id)" size="sm" />
</section>
</div>
</div>
</template>
<style scoped>
.adm-dashboard { display: flex; flex-direction: column; gap: 16px; }
.adm-dashboard__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.adm-dashboard__charts {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
}
.adm-dashboard__pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 1100px) {
.adm-dashboard__charts,
.adm-dashboard__pair { grid-template-columns: 1fr; }
}
.adm-card {
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-6);
overflow: hidden;
}
.adm-card__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--line-1);
}
.adm-card__head h3 {
margin: 0;
font-size: var(--t-13);
font-weight: var(--w-medium);
color: var(--fg-1);
}
.adm-card__body { padding: 12px 14px; }
.adm-card__body--donut {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
</style> <script setup lang="ts">
/**
* 用户管理页 —— 系统化展示 CfTable 的能力:
* - 多选 + 批量删除 + 全选/反选
* - 列排序:id / name / createdAt
* - 列过滤:status(select)
* - 列拖拽换序(reorderable)+ 列宽拖拽(resizable)+ 列隐藏
* - 内联编辑:双击「手机号」单元格直接改
* - 客户端分页 + 翻页器 + 每页数量切换
* - 全局搜索 + 数据 export CSV
* - sticky header + 固定右侧 actions 列
* - 新增 / 编辑 modal(CfForm + CfInput + CfSelect)+ CfConfirmDialog 二次确认
*/
import { computed, h, inject, ref } from 'vue';
import {
CfTable,
CfTag,
CfButton,
CfModal,
CfForm,
CfFormField,
CfInput,
CfSelect,
CfSearchInput,
CfCheckbox,
CfPopover,
CfConfirmDialog,
toast,
} from '@chufix-design/vue';
import type { TableColumn, TableColumnsState } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import { initialUsers, type AdminUser } from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const rows = ref<AdminUser[]>(initialUsers.map((u) => ({ ...u })));
const search = ref('');
const selected = ref<string[]>([]);
const dialogOpen = ref(false);
const editing = ref<AdminUser | null>(null);
const form = ref<Pick<AdminUser, 'username' | 'name' | 'email' | 'phone' | 'status'>>({
username: '', name: '', email: '', phone: '', status: 'active',
});
const confirmOpen = ref(false);
const pendingDelete = ref<AdminUser | null>(null);
const batchConfirmOpen = ref(false);
const columnsState = ref<TableColumnsState>({ hidden: [] });
const allColumnKeys = ['id', 'username', 'name', 'email', 'phone', 'status', 'createdAt', 'actions'];
const columnLabelMap = computed<Record<string, string>>(() => ({
id: t.value.col_id,
username: t.value.col_username,
name: t.value.col_name,
email: t.value.col_email,
phone: t.value.col_phone,
status: t.value.status,
createdAt: t.value.col_created_at,
actions: t.value.actions,
}));
function isHidden(key: string): boolean {
return (columnsState.value.hidden ?? []).includes(key);
}
function toggleColumn(key: string) {
const set = new Set(columnsState.value.hidden ?? []);
if (set.has(key)) set.delete(key);
else set.add(key);
columnsState.value = { ...columnsState.value, hidden: [...set] };
}
function resetColumns() {
columnsState.value = { hidden: [], order: undefined, widths: {} };
}
function openCreate() {
editing.value = null;
form.value = { username: '', name: '', email: '', phone: '', status: 'active' };
dialogOpen.value = true;
}
function openEdit(row: AdminUser) {
editing.value = row;
form.value = {
username: row.username, name: row.name, email: row.email, phone: row.phone, status: row.status,
};
dialogOpen.value = true;
}
function askDelete(row: AdminUser) {
pendingDelete.value = row;
confirmOpen.value = true;
}
function confirmDelete() {
const row = pendingDelete.value;
if (!row) return;
rows.value = rows.value.filter((r) => r.id !== row.id);
selected.value = selected.value.filter((id) => id !== String(row.id));
pendingDelete.value = null;
toast.success(state.locale.value === 'zh' ? '已删除 1 个用户' : 'User deleted');
}
function askBatchDelete() {
if (!selected.value.length) return;
batchConfirmOpen.value = true;
}
function confirmBatchDelete() {
const removed = selected.value.length;
const set = new Set(selected.value);
rows.value = rows.value.filter((r) => !set.has(String(r.id)));
selected.value = [];
toast.success(
state.locale.value === 'zh'
? `已删除 ${removed} 个用户`
: `Deleted ${removed} users`,
);
}
function save() {
if (!form.value.username.trim() || !form.value.name.trim()) {
toast.error(state.locale.value === 'zh' ? '账号和姓名必填' : 'Username and name required');
return false;
}
if (editing.value) {
const id = editing.value.id;
rows.value = rows.value.map((r) => (r.id === id ? { ...r, ...form.value } : r));
toast.success(state.locale.value === 'zh' ? '已更新' : 'Updated');
} else {
const nextId = (rows.value.reduce((m, r) => Math.max(m, r.id), 0) || 0) + 1;
rows.value = [
...rows.value,
{ id: nextId, ...form.value, createdAt: new Date().toISOString().slice(0, 16).replace('T', ' ') },
];
toast.success(state.locale.value === 'zh' ? '已新增' : 'Created');
}
return true;
}
function onCellEdit(payload: { row: AdminUser; column: TableColumn<AdminUser>; oldValue: unknown; newValue: unknown }) {
if (payload.column.key !== 'phone') return;
const v = String(payload.newValue).trim();
rows.value = rows.value.map((r) => (r.id === payload.row.id ? { ...r, phone: v } : r));
toast.success(t.value.phone_updated);
}
function exportCsv() {
const lines = [
['id', 'username', 'name', 'email', 'phone', 'status', 'createdAt'].join(','),
...rows.value.map((r) =>
[r.id, r.username, r.name, r.email, r.phone, r.status, r.createdAt]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(','),
),
];
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'users.csv';
a.click();
URL.revokeObjectURL(url);
toast.info(state.locale.value === 'zh' ? `已导出 ${rows.value.length} 条记录` : `Exported ${rows.value.length} rows`);
}
const cols = computed<TableColumn<AdminUser>[]>(() => [
{ key: 'id', title: t.value.col_id, dataIndex: 'id', width: 60, sortable: true, hideable: true },
{ key: 'username', title: t.value.col_username, dataIndex: 'username', width: 120, sortable: true, hideable: true },
{ key: 'name', title: t.value.col_name, dataIndex: 'name', width: 150, sortable: true, hideable: true },
{ key: 'email', title: t.value.col_email, dataIndex: 'email', width: 220, ellipsis: true, hideable: true },
{
key: 'phone', title: t.value.col_phone, dataIndex: 'phone', width: 150,
editable: true, editType: 'text',
editValidate: (v: unknown) => /^\d{6,15}$/.test(String(v).trim()),
hideable: true,
},
{
key: 'status', title: t.value.status, dataIndex: 'status', width: 120,
filterable: true,
filterType: 'select',
filterOptions: [
{ label: t.value.status_active, value: 'active' },
{ label: t.value.status_disabled, value: 'disabled' },
],
hideable: true,
render: (v: unknown) =>
h(CfTag,
{ size: 'sm', tone: v === 'active' ? 'success' : 'danger', variant: 'soft' },
() => (v === 'active' ? t.value.status_active : t.value.status_disabled)),
},
{ key: 'createdAt', title: t.value.col_created_at, dataIndex: 'createdAt', width: 180, sortable: true, hideable: true },
{
key: 'actions', title: t.value.actions, dataIndex: 'id', width: 150, align: 'right' as const,
fixed: 'right' as const,
reorderable: false,
resizable: false,
render: (_v: unknown, row: AdminUser) =>
h('div', { style: 'display: inline-flex; gap: 4px; justify-content: flex-end;' }, [
h(CfButton, { size: 'sm', variant: 'tertiary', onClick: () => openEdit(row) }, () => t.value.edit),
h(CfButton, { size: 'sm', variant: 'danger', onClick: () => askDelete(row) }, () => t.value.delete),
]),
},
]);
</script>
<template>
<div class="adm-page">
<header class="adm-page__head">
<div class="adm-page__head-left">
<CfSearchInput v-model="search" :placeholder="t.search" size="sm" style="width: 240px;" />
<span class="adm-page__count">
{{ t.total_rows.replace('{n}', String(rows.length)) }}
<template v-if="selected.length">
· {{ state.locale.value === 'zh' ? `已选 ${selected.length} 项` : `${selected.length} selected` }}
</template>
</span>
</div>
<div class="adm-page__head-right">
<CfPopover placement="bottom" :width="220">
<CfButton variant="tertiary" size="sm">
{{ t.column_settings }}
</CfButton>
<template #content>
<div class="adm-columns">
<header class="adm-columns__head">
<span>{{ t.column_settings }}</span>
<button type="button" class="adm-columns__reset" @click="resetColumns">
{{ t.reset_columns }}
</button>
</header>
<ul class="adm-columns__list">
<li v-for="k in allColumnKeys" :key="k">
<label class="adm-columns__item">
<CfCheckbox :model-value="!isHidden(k)" @update:modelValue="toggleColumn(k)" />
<span>{{ columnLabelMap[k] }}</span>
</label>
</li>
</ul>
</div>
</template>
</CfPopover>
<CfButton v-if="selected.length" variant="danger" size="sm" @click="askBatchDelete">
{{ state.locale.value === 'zh' ? `批量删除 (${selected.length})` : `Delete (${selected.length})` }}
</CfButton>
<CfButton variant="tertiary" size="sm" @click="exportCsv">
{{ state.locale.value === 'zh' ? '导出 CSV' : 'Export CSV' }}
</CfButton>
<CfButton variant="primary" size="sm" @click="openCreate">+ {{ t.create }}</CfButton>
</div>
</header>
<CfTable
v-model="selected"
:columns="cols"
:rows="rows"
:row-key="(r: AdminUser) => String(r.id)"
selectable="multiple"
size="sm"
:global-search="search"
:sticky-header="true"
:hoverable="true"
:resizable="true"
:reorderable="true"
persist-key="admin-mini:users"
:columns-state="columnsState"
@update:columns-state="(v: TableColumnsState) => (columnsState = v)"
@cell-edit="onCellEdit"
:pagination="{ page: 1, pageSize: 5, pageSizeOptions: [5, 10, 20], showSizeChanger: true, showJumper: true, showTotal: true }"
/>
<p class="adm-page__tip">
{{ state.locale.value === 'zh'
? '提示:拖拽表头可换列顺序,拖拽列分割线可调列宽,双击「手机号」单元格可直接编辑(需 6–15 位数字)。'
: 'Tip: drag headers to reorder, drag the column splitter to resize, double-click the phone cell to inline-edit (6–15 digits).' }}
</p>
<!-- 新增 / 编辑 modal -->
<CfModal
v-model:open="dialogOpen"
:title="editing ? t.edit : t.create"
:ok-text="t.save"
:cancel-text="t.cancel"
:on-before-ok="save"
size="md"
>
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.col_username" name="username">
<CfInput v-model="form.username" :placeholder="t.col_username" />
</CfFormField>
<CfFormField :label="t.col_name" name="name">
<CfInput v-model="form.name" :placeholder="t.col_name" />
</CfFormField>
<CfFormField :label="t.col_email" name="email">
<CfInput v-model="form.email" type="email" :placeholder="t.col_email" />
</CfFormField>
<CfFormField :label="t.col_phone" name="phone">
<CfInput v-model="form.phone" :placeholder="t.col_phone" />
</CfFormField>
<CfFormField :label="t.status" name="status">
<CfSelect
v-model="form.status"
:options="[
{ value: 'active', label: t.status_active },
{ value: 'disabled', label: t.status_disabled },
]"
/>
</CfFormField>
</CfForm>
</CfModal>
<!-- 单行删除确认 -->
<CfConfirmDialog
v-model:open="confirmOpen"
tone="warning"
:title="state.locale.value === 'zh' ? '确认删除?' : 'Delete this user?'"
:description="pendingDelete ? `${pendingDelete.name} (${pendingDelete.username})` : ''"
:ok-text="t.delete"
:cancel-text="t.cancel"
ok-variant="danger"
@ok="confirmDelete"
/>
<!-- 批量删除确认 -->
<CfConfirmDialog
v-model:open="batchConfirmOpen"
tone="warning"
:title="state.locale.value === 'zh' ? `批量删除 ${selected.length} 个用户?` : `Delete ${selected.length} users?`"
:description="state.locale.value === 'zh' ? '该操作不可撤销。' : 'This action cannot be undone.'"
:ok-text="t.delete"
:cancel-text="t.cancel"
ok-variant="danger"
@ok="confirmBatchDelete"
/>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-page__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.adm-page__head-left,
.adm-page__head-right {
display: inline-flex;
align-items: center;
gap: 8px;
}
.adm-page__count { color: var(--fg-3); font-size: var(--t-12); }
.adm-page__tip {
margin: 0;
color: var(--fg-3);
font-size: var(--t-11);
line-height: 1.6;
}
.adm-columns {
background: var(--bg-1);
min-width: 200px;
}
.adm-columns__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--line-1);
font-size: var(--t-12);
font-weight: var(--w-medium);
color: var(--fg-1);
}
.adm-columns__reset {
background: transparent;
border: 0;
color: var(--accent-1);
font-size: var(--t-11);
cursor: pointer;
padding: 0;
}
.adm-columns__list {
margin: 0;
padding: 6px 0;
list-style: none;
max-height: 260px;
overflow: auto;
}
.adm-columns__item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
cursor: pointer;
font-size: var(--t-12);
color: var(--fg-1);
}
.adm-columns__item:hover { background: var(--bg-2); }
</style> <script setup lang="ts">
/**
* 角色管理页 —— 完整 CRUD:
* - 新增 / 编辑(CfModal + CfForm + CfTreeView 组成菜单 / 按钮权限树)
* - 删除(CfConfirmDialog 二次确认)
* - 多选 + 批量删除
* - 列排序、列过滤(按权限维度)、分页
* - 权限列:tag 列表(点开 CfHoverCard 显示完整权限说明)
*/
import { computed, h, inject, ref } from 'vue';
import {
CfTable,
CfTag,
CfButton,
CfModal,
CfForm,
CfFormField,
CfInput,
CfTextarea,
CfTreeView,
CfConfirmDialog,
CfHoverCard,
toast,
} from '@chufix-design/vue';
import type { TableColumn, TreeNode } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import { initialRoles, type AdminRole } from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const WILDCARD_PERMS: Record<string, string[]> = {
'user:*': ['menu:users', 'user:read', 'user:write'],
'role:*': ['menu:roles', 'role:read', 'role:write'],
'dict:*': ['menu:dict', 'dict:read', 'dict:write'],
'log:*': ['menu:op-log', 'log:read', 'log:export'],
'*': [
'menu:dashboard',
'menu:authz',
'menu:users',
'user:read',
'user:write',
'menu:roles',
'role:read',
'role:write',
'menu:user-roles',
'user-role:read',
'user-role:write',
'menu:org',
'org:read',
'org:write',
'menu:system',
'menu:dict',
'dict:read',
'dict:write',
'menu:op-log',
'log:read',
'log:export',
'menu:login-log',
'login-log:read',
'menu:menus',
'menu:read',
'menu:write',
'menu:settings',
'settings:read',
'settings:write',
],
};
function normalizePermissions(perms: string[]): string[] {
const set = new Set<string>();
for (const perm of perms) {
const expanded = WILDCARD_PERMS[perm] ?? [perm];
for (const key of expanded) set.add(key);
}
return [...set];
}
const rows = ref<AdminRole[]>(initialRoles.map((r) => ({ ...r, permissions: normalizePermissions(r.permissions) })));
const selected = ref<string[]>([]);
const dialogOpen = ref(false);
const editing = ref<AdminRole | null>(null);
const form = ref<{ name: string; description: string; permissions: string[] }>({
name: '', description: '', permissions: [],
});
const confirmOpen = ref(false);
const pendingDelete = ref<AdminRole | null>(null);
const batchConfirmOpen = ref(false);
const permissionTree = computed<TreeNode[]>(() => {
const zh = state.locale.value === 'zh';
return [
{ key: 'menu:dashboard', label: zh ? '菜单:工作台' : 'Menu: Dashboard' },
{
key: 'menu:authz',
label: zh ? '菜单:权限' : 'Menu: Permissions',
children: [
{ key: 'menu:users', label: zh ? '用户管理' : 'Users', children: [
{ key: 'user:read', label: t.value.perm_user_read },
{ key: 'user:write', label: t.value.perm_user_write },
] },
{ key: 'menu:roles', label: zh ? '角色管理' : 'Roles', children: [
{ key: 'role:read', label: t.value.perm_role_read },
{ key: 'role:write', label: t.value.perm_role_write },
] },
{ key: 'menu:user-roles', label: zh ? '用户角色' : 'User roles', children: [
{ key: 'user-role:read', label: zh ? '查看用户角色' : 'View user roles' },
{ key: 'user-role:write', label: zh ? '分配用户角色' : 'Assign user roles' },
] },
{ key: 'menu:org', label: zh ? '组织架构' : 'Organization', children: [
{ key: 'org:read', label: zh ? '查看组织' : 'View organization' },
{ key: 'org:write', label: zh ? '维护组织' : 'Manage organization' },
] },
],
},
{
key: 'menu:system',
label: zh ? '菜单:系统' : 'Menu: System',
children: [
{ key: 'menu:dict', label: zh ? '字典管理' : 'Dictionary', children: [
{ key: 'dict:read', label: t.value.perm_dict_read },
{ key: 'dict:write', label: t.value.perm_dict_write },
] },
{ key: 'menu:op-log', label: zh ? '操作日志' : 'Operation logs', children: [
{ key: 'log:read', label: t.value.perm_log_read },
{ key: 'log:export', label: t.value.perm_log_export },
] },
{ key: 'menu:login-log', label: zh ? '登录日志' : 'Login logs', children: [
{ key: 'login-log:read', label: zh ? '查看登录日志' : 'View login logs' },
] },
{ key: 'menu:menus', label: zh ? '菜单管理' : 'Menu management', children: [
{ key: 'menu:read', label: zh ? '查看菜单' : 'View menus' },
{ key: 'menu:write', label: zh ? '维护菜单' : 'Manage menus' },
] },
{ key: 'menu:settings', label: zh ? '系统设置' : 'Settings', children: [
{ key: 'settings:read', label: zh ? '查看设置' : 'View settings' },
{ key: 'settings:write', label: zh ? '保存设置' : 'Save settings' },
] },
],
},
];
});
const expandedPermissionKeys = computed(() => [
'menu:authz',
'menu:users',
'menu:roles',
'menu:user-roles',
'menu:org',
'menu:system',
'menu:dict',
'menu:op-log',
'menu:login-log',
'menu:menus',
'menu:settings',
]);
function openCreate() {
editing.value = null;
form.value = { name: '', description: '', permissions: [] };
dialogOpen.value = true;
}
function openEdit(row: AdminRole) {
editing.value = row;
form.value = { name: row.name, description: row.description, permissions: [...row.permissions] };
dialogOpen.value = true;
}
function askDelete(row: AdminRole) {
pendingDelete.value = row;
confirmOpen.value = true;
}
function confirmDelete() {
const r = pendingDelete.value;
if (!r) return;
rows.value = rows.value.filter((x) => x.id !== r.id);
selected.value = selected.value.filter((id) => id !== r.id);
pendingDelete.value = null;
toast.success(state.locale.value === 'zh' ? '已删除 1 个角色' : 'Role deleted');
}
function askBatchDelete() {
if (!selected.value.length) return;
batchConfirmOpen.value = true;
}
function confirmBatchDelete() {
const removed = selected.value.length;
const set = new Set(selected.value);
rows.value = rows.value.filter((r) => !set.has(r.id));
selected.value = [];
toast.success(
state.locale.value === 'zh' ? `已删除 ${removed} 个角色` : `Deleted ${removed} roles`,
);
}
function save(): boolean {
if (!form.value.name.trim()) {
toast.error(state.locale.value === 'zh' ? '角色名必填' : 'Role name required');
return false;
}
if (editing.value) {
const id = editing.value.id;
rows.value = rows.value.map((r) => (r.id === id ? { ...r, ...form.value } : r));
toast.success(state.locale.value === 'zh' ? '已更新' : 'Updated');
} else {
const nextId = `r-${Date.now().toString(36)}`;
rows.value = [...rows.value, { id: nextId, ...form.value }];
toast.success(state.locale.value === 'zh' ? '已新增' : 'Created');
}
return true;
}
const cols = computed<TableColumn<AdminRole>[]>(() => [
{ key: 'name', title: t.value.col_role_name, dataIndex: 'name', width: 180, sortable: true },
{ key: 'description', title: t.value.col_role_desc, dataIndex: 'description', ellipsis: true },
{
key: 'permissions', title: t.value.col_role_perms, dataIndex: 'permissions',
render: (v: unknown, row: AdminRole) => {
const perms = v as string[];
const display = perms.slice(0, 3);
const more = perms.length - display.length;
return h(CfHoverCard, { placement: 'top' as const }, {
default: () => h('div', { style: 'display: inline-flex; gap: 4px; flex-wrap: wrap; cursor: help;' }, [
...display.map((p) =>
h(CfTag, { size: 'sm', variant: 'outline' as const, tone: 'primary' as const }, () => p),
),
...(more > 0 ? [h(CfTag, { size: 'sm', variant: 'soft' as const, tone: 'neutral' as const }, () => `+${more}`)] : []),
]),
content: () => h('div', { style: 'padding: 6px 4px; max-width: 240px;' }, [
h('div', { style: 'font-weight: var(--w-medium); margin-bottom: 6px;' }, row.name),
h('div', { style: 'display: flex; flex-direction: column; gap: 4px;' },
perms.length
? perms.map((p) => h('code', { style: 'font-size: 11px; color: var(--fg-2);' }, p))
: [h('span', { style: 'color: var(--fg-3); font-size: 11px;' }, '—')],
),
]),
});
},
},
{
key: 'actions', title: t.value.actions, dataIndex: 'id', width: 140, align: 'right' as const,
fixed: 'right' as const,
render: (_v: unknown, row: AdminRole) =>
h('div', { style: 'display: inline-flex; gap: 4px; justify-content: flex-end;' }, [
h(CfButton, { size: 'sm', variant: 'tertiary', onClick: () => openEdit(row) }, () => t.value.edit),
h(CfButton, { size: 'sm', variant: 'danger', onClick: () => askDelete(row) }, () => t.value.delete),
]),
},
]);
</script>
<template>
<div class="adm-page">
<header class="adm-page__head">
<div class="adm-page__head-left">
<span class="adm-page__count">
{{ t.total_rows.replace('{n}', String(rows.length)) }}
<template v-if="selected.length">
· {{ state.locale.value === 'zh' ? `已选 ${selected.length} 项` : `${selected.length} selected` }}
</template>
</span>
</div>
<div class="adm-page__head-right">
<CfButton v-if="selected.length" variant="danger" size="sm" @click="askBatchDelete">
{{ state.locale.value === 'zh' ? `批量删除 (${selected.length})` : `Delete (${selected.length})` }}
</CfButton>
<CfButton variant="primary" size="sm" @click="openCreate">+ {{ t.create }}</CfButton>
</div>
</header>
<CfTable
v-model="selected"
:columns="cols"
:rows="rows"
:row-key="(r: AdminRole) => r.id"
selectable="multiple"
size="sm"
:sticky-header="true"
:hoverable="true"
:pagination="{ page: 1, pageSize: 5, pageSizeOptions: [5, 10], showSizeChanger: true, showTotal: true }"
/>
<!-- 新增 / 编辑 modal -->
<CfModal
v-model:open="dialogOpen"
:title="editing ? `${t.edit}:${editing.name}` : t.create"
:ok-text="t.save"
:cancel-text="t.cancel"
:on-before-ok="save"
size="md"
>
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.col_role_name" name="name">
<CfInput v-model="form.name" />
</CfFormField>
<CfFormField :label="t.col_role_desc" name="description">
<CfTextarea v-model="form.description" :rows="2" />
</CfFormField>
<CfFormField :label="t.col_role_perms" name="permissions">
<div class="adm-roles__perm-tree">
<CfTreeView
v-model="form.permissions"
:nodes="permissionTree"
:default-expanded-keys="expandedPermissionKeys"
checkable
:cascade="true"
size="sm"
:show-line="true"
/>
</div>
</CfFormField>
</CfForm>
</CfModal>
<!-- 单行删除确认 -->
<CfConfirmDialog
v-model:open="confirmOpen"
tone="warning"
:title="state.locale.value === 'zh' ? '确认删除?' : 'Delete this role?'"
:description="pendingDelete ? pendingDelete.name : ''"
:ok-text="t.delete"
:cancel-text="t.cancel"
ok-variant="danger"
@ok="confirmDelete"
/>
<CfConfirmDialog
v-model:open="batchConfirmOpen"
tone="warning"
:title="state.locale.value === 'zh' ? `批量删除 ${selected.length} 个角色?` : `Delete ${selected.length} roles?`"
:description="state.locale.value === 'zh' ? '已分配用户将丢失对应角色,该操作不可撤销。' : 'Users with these roles will lose them. This cannot be undone.'"
:ok-text="t.delete"
:cancel-text="t.cancel"
ok-variant="danger"
@ok="confirmBatchDelete"
/>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-page__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.adm-page__head-left,
.adm-page__head-right {
display: inline-flex;
align-items: center;
gap: 8px;
}
.adm-page__count { color: var(--fg-3); font-size: var(--t-12); }
.adm-roles__perm-tree {
max-height: 320px;
overflow: auto;
padding: 8px;
border: 1px solid var(--line-1);
border-radius: var(--r-4);
background: var(--bg-1);
}
</style> <script setup lang="ts">
import { computed, h, inject, ref } from 'vue';
import { CfTable, CfTag, CfButton, CfModal, CfCheckbox } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import {
initialUsers,
initialRoles,
initialUserRoles,
type AdminUser,
type UserRoleLink,
} from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const links = ref<UserRoleLink[]>(initialUserRoles.map((l) => ({ ...l, roleIds: [...l.roleIds] })));
function rolesFor(userId: number): string[] {
return links.value.find((l) => l.userId === userId)?.roleIds ?? [];
}
function roleName(id: string): string {
return initialRoles.find((r) => r.id === id)?.name ?? id;
}
const dialogOpen = ref(false);
const editingUser = ref<AdminUser | null>(null);
const draft = ref<Set<string>>(new Set());
function openAssign(u: AdminUser) {
editingUser.value = u;
draft.value = new Set(rolesFor(u.id));
dialogOpen.value = true;
}
function toggleRole(roleId: string) {
if (draft.value.has(roleId)) draft.value.delete(roleId);
else draft.value.add(roleId);
draft.value = new Set(draft.value);
}
function saveAssign() {
if (!editingUser.value) return;
const uid = editingUser.value.id;
const next = [...draft.value];
const exists = links.value.some((l) => l.userId === uid);
links.value = exists
? links.value.map((l) => (l.userId === uid ? { ...l, roleIds: next } : l))
: [...links.value, { userId: uid, roleIds: next }];
dialogOpen.value = false;
}
const cols = computed(() => [
{ key: 'username', title: t.value.col_username, dataIndex: 'username', width: 110 },
{ key: 'name', title: t.value.col_name, dataIndex: 'name', width: 140 },
{
key: 'roles', title: t.value.col_user_roles, dataIndex: 'id',
render: (v: unknown) => {
const ids = rolesFor(v as number);
if (!ids.length) {
return h('span', { style: 'color: var(--fg-3);' }, '—');
}
return h('div', { style: 'display: inline-flex; gap: 4px; flex-wrap: wrap;' },
ids.map((id) =>
h(CfTag, { size: 'sm', variant: 'soft', tone: 'primary' }, () => roleName(id)),
),
);
},
},
{
key: 'assign', title: t.value.col_assign, dataIndex: 'id', width: 110, align: 'right' as const,
render: (_v: unknown, row: AdminUser) =>
h(CfButton, { size: 'sm', variant: 'tertiary', onClick: () => openAssign(row) }, () => t.value.edit),
},
]);
</script>
<template>
<div class="adm-page">
<CfTable :columns="cols" :rows="initialUsers" :row-key="(r: AdminUser) => r.id" size="sm" />
<CfModal
v-model:open="dialogOpen"
:title="editingUser ? `${editingUser.name} · ${t.col_assign}` : t.col_assign"
:ok-text="t.save"
:cancel-text="t.cancel"
:on-before-ok="() => { saveAssign(); return true; }"
size="sm"
>
<div class="adm-assign">
<label v-for="r in initialRoles" :key="r.id" class="adm-assign__row">
<CfCheckbox :model-value="draft.has(r.id)" @update:modelValue="toggleRole(r.id)" />
<span class="adm-assign__name">{{ r.name }}</span>
<span class="adm-assign__desc">{{ r.description }}</span>
</label>
</div>
</CfModal>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-assign { display: flex; flex-direction: column; gap: 8px; }
.adm-assign__row {
display: grid;
grid-template-columns: 22px 120px 1fr;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--r-4);
cursor: pointer;
}
.adm-assign__row:hover { background: var(--bg-2); }
.adm-assign__name { font-weight: var(--w-medium); color: var(--fg-1); }
.adm-assign__desc { color: var(--fg-3); font-size: var(--t-12); }
</style> <script setup lang="ts">
/**
* 字典管理页 —— 父子树形表格 + 完整 CRUD:
* - 树形 CfTable(父字典 → 子项)+ 默认展开
* - 新增父字典 / 新增子项 / 编辑 / 删除
* - 删除二次确认(CfConfirmDialog),删除父字典会连带删除其所有子项
*/
import { computed, h, inject, ref } from 'vue';
import {
CfTable,
CfTag,
CfButton,
CfModal,
CfForm,
CfFormField,
CfInput,
CfTextarea,
CfConfirmDialog,
toast,
} from '@chufix-design/vue';
import type { TableColumn } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import { initialDict, type DictItem } from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
interface DictNode extends DictItem {
children?: DictNode[];
}
const rows = ref<DictItem[]>(initialDict.map((d) => ({ ...d })));
const tree = computed<DictNode[]>(() => {
const byParent = new Map<string | null, DictItem[]>();
for (const item of rows.value) {
const arr = byParent.get(item.parentId) ?? [];
arr.push(item);
byParent.set(item.parentId, arr);
}
const roots = byParent.get(null) ?? [];
return roots.map((r) => ({ ...r, children: byParent.get(r.id) ?? [] }));
});
const dialogOpen = ref(false);
const editing = ref<DictItem | null>(null);
const parentOf = ref<DictItem | null>(null);
const form = ref<{ label: string; value: string; remark: string }>({
label: '', value: '', remark: '',
});
const confirmOpen = ref(false);
const pendingDelete = ref<DictItem | null>(null);
function openCreateRoot() {
editing.value = null;
parentOf.value = null;
form.value = { label: '', value: '', remark: '' };
dialogOpen.value = true;
}
function openCreateChild(parent: DictItem) {
editing.value = null;
parentOf.value = parent;
form.value = { label: '', value: '', remark: '' };
dialogOpen.value = true;
}
function openEdit(row: DictItem) {
editing.value = row;
parentOf.value = null;
form.value = { label: row.label, value: row.value, remark: row.remark };
dialogOpen.value = true;
}
function askDelete(row: DictItem) {
pendingDelete.value = row;
confirmOpen.value = true;
}
function confirmDelete() {
const r = pendingDelete.value;
if (!r) return;
const removeIds = new Set<string>([r.id]);
// 父字典连带删除全部子项
for (const it of rows.value) {
if (it.parentId === r.id) removeIds.add(it.id);
}
rows.value = rows.value.filter((x) => !removeIds.has(x.id));
pendingDelete.value = null;
toast.success(
state.locale.value === 'zh'
? `已删除 ${removeIds.size} 个条目`
: `Deleted ${removeIds.size} entries`,
);
}
function save(): boolean {
if (!form.value.label.trim() || !form.value.value.trim()) {
toast.error(state.locale.value === 'zh' ? '名称和值必填' : 'Label and value required');
return false;
}
if (editing.value) {
const id = editing.value.id;
rows.value = rows.value.map((r) => (r.id === id ? { ...r, ...form.value } : r));
toast.success(state.locale.value === 'zh' ? '已更新' : 'Updated');
} else {
const nextId = `d-${Date.now().toString(36)}`;
rows.value = [
...rows.value,
{ id: nextId, parentId: parentOf.value?.id ?? null, ...form.value },
];
toast.success(state.locale.value === 'zh' ? '已新增' : 'Created');
}
return true;
}
const dialogTitle = computed(() => {
if (editing.value) return `${t.value.edit}:${editing.value.label}`;
if (parentOf.value) {
return state.locale.value === 'zh'
? `新增子项 → ${parentOf.value.label}`
: `New child → ${parentOf.value.label}`;
}
return state.locale.value === 'zh' ? '新增父字典' : 'New root entry';
});
const deleteDescription = computed(() => {
const r = pendingDelete.value;
if (!r) return '';
if (r.parentId == null) {
return state.locale.value === 'zh'
? `将连带删除「${r.label}」的所有子项,无法撤销。`
: `All children of "${r.label}" will also be deleted. This cannot be undone.`;
}
return r.label;
});
const cols = computed<TableColumn<DictNode>[]>(() => [
{
key: 'label', title: t.value.col_dict_label, dataIndex: 'label', width: 240,
render: (v: unknown, row: DictNode) => {
const children = [h('span', String(v))];
if (row.parentId == null) {
children.push(
h(CfTag, { size: 'sm', variant: 'soft', tone: 'primary' },
() => state.locale.value === 'zh' ? '父字典' : 'root'),
);
}
return h('span', { style: 'display: inline-flex; align-items: center; gap: 6px;' }, children);
},
},
{ key: 'value', title: t.value.col_dict_value, dataIndex: 'value', width: 200 },
{ key: 'remark', title: t.value.col_dict_remark, dataIndex: 'remark', ellipsis: true },
{
key: 'actions', title: t.value.actions, dataIndex: 'id', width: 220, align: 'right' as const,
fixed: 'right' as const,
render: (_v: unknown, row: DictNode) => {
const buttons = [] as unknown[];
if (row.parentId == null) {
buttons.push(
h(CfButton, { size: 'sm', variant: 'tertiary', onClick: () => openCreateChild(row) },
() => state.locale.value === 'zh' ? '+ 子项' : '+ Child'),
);
}
buttons.push(
h(CfButton, { size: 'sm', variant: 'tertiary', onClick: () => openEdit(row) }, () => t.value.edit),
h(CfButton, { size: 'sm', variant: 'danger', onClick: () => askDelete(row) }, () => t.value.delete),
);
return h('div', { style: 'display: inline-flex; gap: 4px; justify-content: flex-end;' }, buttons);
},
},
]);
const expandedKeys = computed(() => tree.value.map((n) => n.id));
</script>
<template>
<div class="adm-page">
<header class="adm-page__head">
<span class="adm-page__count">
{{ state.locale.value === 'zh'
? `共 ${rows.length} 个条目(${tree.length} 个父字典)`
: `${rows.length} entries (${tree.length} roots)` }}
</span>
<CfButton variant="primary" size="sm" @click="openCreateRoot">
+ {{ state.locale.value === 'zh' ? '新增父字典' : 'New root' }}
</CfButton>
</header>
<CfTable
:columns="cols"
:rows="tree"
:row-key="(r: DictNode) => r.id"
:default-expanded-row-keys="expandedKeys"
size="sm"
:sticky-header="true"
:hoverable="true"
/>
<CfModal
v-model:open="dialogOpen"
:title="dialogTitle"
:ok-text="t.save"
:cancel-text="t.cancel"
:on-before-ok="save"
size="sm"
>
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.col_dict_label" name="label">
<CfInput v-model="form.label" />
</CfFormField>
<CfFormField :label="t.col_dict_value" name="value">
<CfInput v-model="form.value" />
</CfFormField>
<CfFormField :label="t.col_dict_remark" name="remark">
<CfTextarea v-model="form.remark" :rows="2" />
</CfFormField>
</CfForm>
</CfModal>
<CfConfirmDialog
v-model:open="confirmOpen"
tone="warning"
:title="state.locale.value === 'zh' ? '确认删除?' : 'Delete this entry?'"
:description="deleteDescription"
:ok-text="t.delete"
:cancel-text="t.cancel"
ok-variant="danger"
@ok="confirmDelete"
/>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-page__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.adm-page__count { color: var(--fg-3); font-size: var(--t-12); }
</style> <script setup lang="ts">
/**
* 操作日志 —— 展示更多 CfTable 能力:
* - 全局搜索 + 按 action / status 列过滤
* - 排序:时间倒序为默认
* - 多选 + 批量删除(演示态)
* - 分页 / 翻页 / 调整每页数量
* - sticky header + 固定右侧 actions 列
* - 导出 CSV
* - row-click 展开详情(CfDescriptionList)
*/
import { computed, h, inject, ref } from 'vue';
import {
CfTable,
CfTag,
CfButton,
CfSearchInput,
CfDescriptionList,
CfConfirmDialog,
toast,
} from '@chufix-design/vue';
import type { TableColumn, DescriptionItem } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import { initialOpLogs, type OperationLog } from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const rows = ref<OperationLog[]>(initialOpLogs.map((r) => ({ ...r })));
const search = ref('');
const selected = ref<string[]>([]);
const expanded = ref<string[]>([]);
const batchConfirmOpen = ref(false);
function askBatchDelete() {
if (selected.value.length) batchConfirmOpen.value = true;
}
function confirmBatchDelete() {
const set = new Set(selected.value);
const n = selected.value.length;
rows.value = rows.value.filter((r) => !set.has(String(r.id)));
selected.value = [];
toast.success(state.locale.value === 'zh' ? `已删除 ${n} 条日志` : `Deleted ${n} log entries`);
}
function exportCsv() {
const lines = [
['id', 'user', 'action', 'resource', 'ip', 'at', 'status'].join(','),
...rows.value.map((r) =>
[r.id, r.user, r.action, r.resource, r.ip, r.at, r.status]
.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(','),
),
];
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'operation-log.csv';
a.click();
URL.revokeObjectURL(a.href);
toast.info(state.locale.value === 'zh' ? `已导出 ${rows.value.length} 条记录` : `Exported ${rows.value.length} rows`);
}
const cols = computed<TableColumn<OperationLog>[]>(() => [
{ key: 'user', title: t.value.col_log_user, dataIndex: 'user', width: 110, sortable: true },
{
key: 'action', title: t.value.col_log_action, dataIndex: 'action', width: 110,
filterable: true,
filterType: 'select',
filterOptions: [
{ label: 'create', value: 'create' },
{ label: 'update', value: 'update' },
{ label: 'delete', value: 'delete' },
{ label: 'login', value: 'login' },
{ label: 'export', value: 'export' },
],
render: (v: unknown) =>
h(CfTag, { size: 'sm', variant: 'outline', tone: v === 'delete' ? 'danger' : 'neutral' }, () => String(v)),
},
{ key: 'resource', title: t.value.col_log_resource, dataIndex: 'resource', ellipsis: true },
{ key: 'ip', title: t.value.col_log_ip, dataIndex: 'ip', width: 130 },
{
key: 'status', title: t.value.status, dataIndex: 'status', width: 100,
filterable: true,
filterType: 'select',
filterOptions: [
{ label: t.value.status_ok, value: 'ok' },
{ label: t.value.status_fail, value: 'fail' },
],
render: (v: unknown) =>
h(CfTag,
{ size: 'sm', tone: v === 'ok' ? 'success' : 'danger', variant: 'soft' },
() => (v === 'ok' ? t.value.status_ok : t.value.status_fail)),
},
{ key: 'at', title: t.value.col_log_at, dataIndex: 'at', width: 180, sortable: true },
]);
function descItems(row: OperationLog): DescriptionItem[] {
const actionText = state.locale.value === 'zh'
? ({ create: '新增用户', update: '更新角色权限', delete: '删除用户', login: '登录认证', export: '导出审计日志' } as Record<string, string>)[row.action] ?? row.action
: ({ create: 'Create user', update: 'Update role permissions', delete: 'Delete user', login: 'Login auth', export: 'Export audit logs' } as Record<string, string>)[row.action] ?? row.action;
const resultText = row.status === 'ok'
? (state.locale.value === 'zh' ? '请求已完成,审计事件已写入日志。' : 'Request completed and audit event was persisted.')
: (state.locale.value === 'zh' ? '权限校验未通过,后端拒绝执行。' : 'Permission check failed and the backend rejected the request.');
return [
{ term: t.value.col_log_user, description: row.user },
{ term: t.value.col_log_action, description: `${row.action} · ${actionText}` },
{ term: t.value.col_log_resource, description: row.resource },
{ term: t.value.col_log_ip, description: row.ip },
{ term: t.value.col_log_at, description: row.at },
{ term: state.locale.value === 'zh' ? '请求编号' : 'Request ID', description: `REQ-${String(row.id).padStart(5, '0')}` },
{ term: state.locale.value === 'zh' ? '请求方法' : 'Method', description: row.action === 'login' ? 'POST' : row.action === 'delete' ? 'DELETE' : row.action === 'create' ? 'POST' : 'PATCH' },
{ term: t.value.status, description: row.status === 'ok' ? t.value.status_ok : t.value.status_fail },
{ term: state.locale.value === 'zh' ? '处理结果' : 'Result', description: resultText },
];
}
</script>
<template>
<div class="adm-page">
<header class="adm-page__head">
<div class="adm-page__head-left">
<CfSearchInput v-model="search" :placeholder="t.search" size="sm" style="width: 240px;" />
<span class="adm-page__count">
{{ t.total_rows.replace('{n}', String(rows.length)) }}
<template v-if="selected.length">
· {{ state.locale.value === 'zh' ? `已选 ${selected.length} 项` : `${selected.length} selected` }}
</template>
</span>
</div>
<div class="adm-page__head-right">
<CfButton v-if="selected.length" variant="danger" size="sm" @click="askBatchDelete">
{{ state.locale.value === 'zh' ? `批量删除 (${selected.length})` : `Delete (${selected.length})` }}
</CfButton>
<CfButton variant="tertiary" size="sm" @click="exportCsv">
{{ state.locale.value === 'zh' ? '导出 CSV' : 'Export CSV' }}
</CfButton>
</div>
</header>
<CfTable
v-model="selected"
:columns="cols"
:rows="rows"
:row-key="(r: OperationLog) => String(r.id)"
selectable="multiple"
size="sm"
:global-search="search"
:default-sort="{ key: 'at', direction: 'desc' }"
:sticky-header="true"
:hoverable="true"
:expandable="true"
v-model:expanded-row-keys="expanded"
:expand-render="(row: OperationLog) => h('div', { style: 'padding: 8px 12px;' }, [h(CfDescriptionList, { items: descItems(row), layout: 'horizontal', size: 'sm' })])"
:pagination="{ page: 1, pageSize: 5, pageSizeOptions: [5, 10, 20], showSizeChanger: true, showJumper: true, showTotal: true }"
/>
<CfConfirmDialog
v-model:open="batchConfirmOpen"
tone="warning"
:title="state.locale.value === 'zh' ? `删除 ${selected.length} 条日志?` : `Delete ${selected.length} log entries?`"
:description="state.locale.value === 'zh' ? '审计日志一般不应删除,此处仅作演示。' : 'Audit logs are typically immutable — this is demo only.'"
:ok-text="t.delete"
:cancel-text="t.cancel"
ok-variant="danger"
@ok="confirmBatchDelete"
/>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-page__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.adm-page__head-left,
.adm-page__head-right {
display: inline-flex;
align-items: center;
gap: 8px;
}
.adm-page__count { color: var(--fg-3); font-size: var(--t-12); }
</style> <script setup lang="ts">
/**
* 登录日志 —— 类似操作日志,重点展示:
* - 失败次数统计 + 高亮失败用户(rowClass)
* - 按状态过滤
* - 排序时间倒序为默认
* - 分页 + 导出 CSV
*/
import { computed, h, inject, ref } from 'vue';
import {
CfTable,
CfTag,
CfButton,
CfSearchInput,
toast,
} from '@chufix-design/vue';
import type { TableColumn } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import { initialLoginLogs, type LoginLog } from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const rows = ref<LoginLog[]>(initialLoginLogs.map((r) => ({ ...r })));
const search = ref('');
function exportCsv() {
const lines = [
['id', 'user', 'ip', 'ua', 'at', 'status'].join(','),
...rows.value.map((r) =>
[r.id, r.user, r.ip, r.ua, r.at, r.status]
.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(','),
),
];
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'login-log.csv';
a.click();
URL.revokeObjectURL(a.href);
toast.info(state.locale.value === 'zh' ? `已导出 ${rows.value.length} 条记录` : `Exported ${rows.value.length} rows`);
}
const cols = computed<TableColumn<LoginLog>[]>(() => [
{ key: 'user', title: t.value.col_log_user, dataIndex: 'user', width: 110, sortable: true },
{ key: 'ip', title: t.value.col_log_ip, dataIndex: 'ip', width: 140 },
{ key: 'ua', title: t.value.col_log_ua, dataIndex: 'ua', ellipsis: true },
{
key: 'status', title: t.value.status, dataIndex: 'status', width: 110,
filterable: true,
filterType: 'select',
filterOptions: [
{ label: t.value.status_success, value: 'success' },
{ label: t.value.status_failed, value: 'failed' },
],
render: (v: unknown) =>
h(CfTag,
{ size: 'sm', tone: v === 'success' ? 'success' : 'danger', variant: 'soft' },
() => (v === 'success' ? t.value.status_success : t.value.status_failed)),
},
{ key: 'at', title: t.value.col_log_at, dataIndex: 'at', width: 180, sortable: true },
]);
const failCount = computed(() => rows.value.filter((r) => r.status === 'failed').length);
</script>
<template>
<div class="adm-page">
<header class="adm-page__head">
<div class="adm-page__head-left">
<CfSearchInput v-model="search" :placeholder="t.search" size="sm" style="width: 240px;" />
<span class="adm-page__count">
{{ t.total_rows.replace('{n}', String(rows.length)) }}
<template v-if="failCount">
· <span style="color: oklch(70% 0.15 25);">{{ state.locale.value === 'zh' ? `失败 ${failCount} 次` : `${failCount} failed` }}</span>
</template>
</span>
</div>
<div class="adm-page__head-right">
<CfButton variant="tertiary" size="sm" @click="exportCsv">
{{ state.locale.value === 'zh' ? '导出 CSV' : 'Export CSV' }}
</CfButton>
</div>
</header>
<CfTable
:columns="cols"
:rows="rows"
:row-key="(r: LoginLog) => String(r.id)"
size="sm"
:global-search="search"
:default-sort="{ key: 'at', direction: 'desc' }"
:sticky-header="true"
:hoverable="true"
:pagination="{ page: 1, pageSize: 5, pageSizeOptions: [5, 10, 20], showSizeChanger: true, showJumper: true, showTotal: true }"
/>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-page__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.adm-page__head-left,
.adm-page__head-right {
display: inline-flex;
align-items: center;
gap: 8px;
}
.adm-page__count { color: var(--fg-3); font-size: var(--t-12); }
</style> <script setup lang="ts">
/**
* 系统设置页 —— 展示更多表单组件:
* CfSwitch · CfRadioGroup · CfSlider · CfNumberInput · CfDatePicker · CfDropzone
*/
import { computed, inject, ref } from 'vue';
import {
CfTabs,
CfTabPanel,
CfForm,
CfFormField,
CfSwitch,
CfRadioGroup,
CfRadio,
CfSlider,
CfNumberInput,
CfDatePicker,
CfDropzone,
CfButton,
CfTag,
toast,
} from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const initialForm = () => ({
twoFactor: true,
logRetention: 90,
sessionTimeout: 30,
passwordPolicy: 'strict' as 'basic' | 'strict' | 'paranoid',
nextBackup: '2026-05-13',
files: [] as File[],
});
const form = ref(initialForm());
function save() {
toast.success(t.value.sys_saved);
}
function revert() {
form.value = initialForm();
toast.info(t.value.sys_reverted);
}
const activeTab = ref<'general' | 'security' | 'backup'>('general');
const tabItems = computed(() => [
{ value: 'general', label: t.value.sys_general },
{ value: 'security', label: t.value.sys_security },
{ value: 'backup', label: t.value.sys_backup },
]);
</script>
<template>
<div class="adm-page">
<CfTabs v-model="activeTab" :items="tabItems" variant="line">
<template #default="{ active }">
<CfTabPanel v-show="active === 'general'" value="general" :label="t.sys_general">
<div class="adm-settings">
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.sys_log_retention" name="logRetention">
<div class="adm-settings__row">
<CfSlider
v-model="form.logRetention"
:min="7"
:max="365"
:step="1"
show-value
style="flex: 1;"
/>
<CfTag size="sm" tone="info">{{ form.logRetention }} {{ state.locale.value === 'zh' ? '天' : 'days' }}</CfTag>
</div>
<p class="adm-settings__hint">{{ t.sys_log_retention_hint }}</p>
</CfFormField>
<CfFormField :label="t.sys_session_timeout" name="sessionTimeout">
<CfNumberInput v-model="form.sessionTimeout" :min="5" :max="240" :step="5" style="width: 160px;" />
</CfFormField>
</CfForm>
</div>
</CfTabPanel>
<CfTabPanel v-show="active === 'security'" value="security" :label="t.sys_security">
<div class="adm-settings">
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.sys_two_factor" name="twoFactor">
<div class="adm-settings__row">
<CfSwitch v-model="form.twoFactor" />
<span class="adm-settings__hint adm-settings__hint--inline">{{ t.sys_two_factor_hint }}</span>
</div>
</CfFormField>
<CfFormField :label="t.sys_password_policy" name="passwordPolicy">
<CfRadioGroup v-model="form.passwordPolicy">
<CfRadio value="basic">{{ t.sys_password_policy_basic }}</CfRadio>
<CfRadio value="strict">{{ t.sys_password_policy_strict }}</CfRadio>
<CfRadio value="paranoid">{{ t.sys_password_policy_paranoid }}</CfRadio>
</CfRadioGroup>
</CfFormField>
</CfForm>
</div>
</CfTabPanel>
<CfTabPanel v-show="active === 'backup'" value="backup" :label="t.sys_backup">
<div class="adm-settings">
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.sys_next_backup" name="nextBackup">
<CfDatePicker v-model="form.nextBackup" :placeholder="t.sys_next_backup" style="width: 220px;" />
</CfFormField>
<CfFormField :label="t.sys_upload_logs" name="files">
<CfDropzone
v-model="form.files"
multiple
:max-size="20 * 1024 * 1024"
accept=".log,.txt,.gz,application/gzip,text/plain"
/>
</CfFormField>
</CfForm>
</div>
</CfTabPanel>
</template>
</CfTabs>
<footer class="adm-page__foot">
<CfButton variant="tertiary" @click="revert">{{ t.sys_revert }}</CfButton>
<CfButton variant="primary" @click="save">{{ t.sys_save_changes }}</CfButton>
</footer>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-settings { padding: 16px 4px 4px; max-width: 720px; }
.adm-settings__row {
display: inline-flex;
align-items: center;
gap: 12px;
width: 100%;
}
.adm-settings__hint {
margin: 6px 0 0;
color: var(--fg-3);
font-size: var(--t-11);
line-height: 1.6;
}
.adm-settings__hint--inline {
margin: 0;
}
.adm-page__foot {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid var(--line-1);
}
</style> <script setup lang="ts">
/**
* 组织架构页 —— 左侧 CfTreeView(部门树)+ 右侧 CfTable(该部门下的成员)。
* 演示树形单选 + 联动表格 + 默认展开 + 选中节点过滤数据集。
*/
import { computed, h, inject, ref } from 'vue';
import { CfTreeView, CfTable, CfTag, CfSplitter, CfBreadcrumb } from '@chufix-design/vue';
import type { TableColumn, TreeNode } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import {
initialUsers,
initialDepartments,
collectDeptKeys,
findDepartment,
type AdminUser,
type Department,
} from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const selectedKey = ref<string>('d-root');
const expandedKeys = ref<string[]>(['d-root', 'd-rnd', 'd-ops']);
function toTreeNodes(list: Department[]): TreeNode[] {
return list.map((d) => ({
key: d.key,
label: d.label,
children: d.children ? toTreeNodes(d.children) : undefined,
}));
}
const treeNodes = computed<TreeNode[]>(() => toTreeNodes(initialDepartments));
const visibleUsers = computed<AdminUser[]>(() => {
const dept = findDepartment(selectedKey.value);
if (!dept) return initialUsers;
const allowed = new Set(collectDeptKeys(dept));
return initialUsers.filter((u) => u.deptKey && allowed.has(u.deptKey));
});
const breadcrumbItems = computed(() => {
const dept = findDepartment(selectedKey.value);
return dept ? [{ label: dept.label }] : [];
});
const cols = computed<TableColumn<AdminUser>[]>(() => [
{ key: 'username', title: t.value.col_username, dataIndex: 'username', width: 120 },
{ key: 'name', title: t.value.col_name, dataIndex: 'name', width: 140 },
{ key: 'email', title: t.value.col_email, dataIndex: 'email', ellipsis: true },
{
key: 'deptKey', title: t.value.col_dept, dataIndex: 'deptKey', width: 160,
render: (v: unknown) => {
const dept = v ? findDepartment(String(v)) : null;
return dept
? h(CfTag, { size: 'sm', variant: 'soft', tone: 'primary' }, () => dept.label)
: h('span', { style: 'color: var(--fg-3);' }, '—');
},
},
{
key: 'status', title: t.value.status, dataIndex: 'status', width: 100,
render: (v: unknown) =>
h(CfTag,
{ size: 'sm', tone: v === 'active' ? 'success' : 'danger', variant: 'soft' },
() => (v === 'active' ? t.value.status_active : t.value.status_disabled)),
},
]);
</script>
<template>
<div class="adm-page">
<CfBreadcrumb v-if="breadcrumbItems.length" :items="breadcrumbItems" />
<p class="adm-page__hint">{{ t.org_select_hint }}</p>
<CfSplitter orientation="horizontal" :default-size="30" unit="%" class="adm-org">
<template #start>
<div class="adm-org__tree">
<CfTreeView
:nodes="treeNodes"
selectable="single"
:selected-key="selectedKey"
v-model:expanded-keys="expandedKeys"
:show-line="true"
size="sm"
@update:selectedKey="(k) => (selectedKey = k ?? 'd-root')"
/>
</div>
</template>
<template #end>
<div class="adm-org__main">
<div class="adm-org__count">
{{ t.org_total.replace('{n}', String(visibleUsers.length)) }}
</div>
<CfTable
:columns="cols"
:rows="visibleUsers"
:row-key="(r: AdminUser) => String(r.id)"
size="sm"
:sticky-header="true"
:hoverable="true"
/>
</div>
</template>
</CfSplitter>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-page__hint { margin: 0; color: var(--fg-3); font-size: var(--t-12); }
.adm-org { min-height: 420px; }
.adm-org__tree {
height: 100%;
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-4);
padding: 8px;
overflow: auto;
}
.adm-org__main {
height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
padding-left: 12px;
}
.adm-org__count {
color: var(--fg-3);
font-size: var(--t-12);
}
</style> <script setup lang="ts">
/**
* 菜单管理 —— 左侧 CfTreeView 展示菜单层级,右侧 CfForm 编辑选中节点。
* 关键 ChuFix 组件展示:
* - CfTreeView:层级结构、显示连接线、单选、节点图标
* - CfIconPicker:图标选择
* - CfForm + CfInput + CfNumberInput + CfSwitch + CfTreeSelect:节点元数据
* - CfSplitter:左右两栏可拖拽
* - CfConfirmDialog:删除二次确认
*/
import { computed, inject, ref, watch } from 'vue';
import {
CfTreeView,
CfSplitter,
CfForm,
CfFormField,
CfInput,
CfNumberInput,
CfSwitch,
CfTreeSelect,
CfIconPicker,
CfButton,
CfConfirmDialog,
CfIcon,
CfEmpty,
toast,
} from '@chufix-design/vue';
import type { TreeNode, TreeSelectNode } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import { initialMenus, type MenuEntry } from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const menus = ref<MenuEntry[]>(initialMenus.map((m) => ({ ...m })));
const selectedKey = ref<string | null>('m-dashboard');
const expandedKeys = ref<string[]>(['m-authz', 'm-system']);
const draft = ref<MenuEntry | null>(null);
const isCreating = ref(false);
function buildTree(list: MenuEntry[], parentId: string | null = null): TreeNode[] {
return list
.filter((m) => m.parentId === parentId)
.sort((a, b) => a.sort - b.sort)
.map<TreeNode>((m) => {
const children = buildTree(list, m.id);
const iconSpan = m.icon
? `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3" /></svg>`
: undefined;
return {
key: m.id,
label: m.title + (m.visible ? '' : ' (hidden)'),
icon: iconSpan,
children: children.length ? children : undefined,
};
});
}
const treeNodes = computed<TreeNode[]>(() => buildTree(menus.value));
function collectMenuDescendantIds(id: string): string[] {
const out: string[] = [];
const visit = (parentId: string) => {
for (const child of menus.value.filter((m) => m.parentId === parentId)) {
out.push(child.id);
visit(child.id);
}
};
visit(id);
return out;
}
function buildParentTree(parentId: string | null, disabledIds: Set<string>): TreeSelectNode[] {
return menus.value
.filter((m) => m.parentId === parentId)
.sort((a, b) => a.sort - b.sort)
.map((m) => ({
value: m.id,
label: m.title,
disabled: disabledIds.has(m.id),
children: buildParentTree(m.id, disabledIds),
}));
}
const parentTreeOptions = computed<TreeSelectNode[]>(() => {
const disabledIds = new Set<string>();
if (draft.value && !isCreating.value) {
disabledIds.add(draft.value.id);
for (const id of collectMenuDescendantIds(draft.value.id)) disabledIds.add(id);
}
return [{
value: '__root__',
label: t.value.menu_no_parent,
children: buildParentTree(null, disabledIds),
}];
});
const parentSelectValue = computed({
get: () => draft.value?.parentId ?? '__root__',
set: (value: string | string[] | undefined) => {
if (!draft.value || Array.isArray(value)) return;
draft.value.parentId = value && value !== '__root__' ? value : null;
},
});
watch(selectedKey, (key) => {
if (isCreating.value && key === null) return;
const found = key ? menus.value.find((m) => m.id === key) : null;
isCreating.value = false;
draft.value = found ? { ...found } : null;
}, { immediate: true });
function saveDraft() {
if (!draft.value) return;
const d = draft.value;
if (!d.title.trim()) {
toast.error(state.locale.value === 'zh' ? '名称必填' : 'Title is required');
return;
}
if (!isCreating.value) {
const disabledIds = new Set([d.id, ...collectMenuDescendantIds(d.id)]);
if (d.parentId && disabledIds.has(d.parentId)) {
toast.error(state.locale.value === 'zh' ? '不能选择自身或子级作为父级' : 'Cannot choose itself or a descendant as parent');
return;
}
}
if (isCreating.value) {
menus.value = [...menus.value, { ...d }];
if (d.parentId) expandedKeys.value = [...new Set([...expandedKeys.value, d.parentId])];
selectedKey.value = d.id;
isCreating.value = false;
toast.success(state.locale.value === 'zh' ? '已新增菜单' : 'Menu created');
return;
}
menus.value = menus.value.map((m) => (m.id === d.id ? { ...d } : m));
if (d.parentId) expandedKeys.value = [...new Set([...expandedKeys.value, d.parentId])];
toast.success(state.locale.value === 'zh' ? '已更新' : 'Updated');
}
const confirmOpen = ref(false);
function askDelete() {
if (!selectedKey.value) return;
confirmOpen.value = true;
}
function confirmDelete() {
if (!selectedKey.value) return;
const removeIds = new Set<string>();
const queue = [selectedKey.value];
while (queue.length) {
const id = queue.shift()!;
removeIds.add(id);
for (const m of menus.value) if (m.parentId === id) queue.push(m.id);
}
menus.value = menus.value.filter((m) => !removeIds.has(m.id));
selectedKey.value = null;
draft.value = null;
toast.success(
state.locale.value === 'zh'
? `已删除 ${removeIds.size} 个菜单项`
: `Deleted ${removeIds.size} menu entries`,
);
}
function openCreate() {
const nextSort = menus.value.filter((m) => m.parentId === null).length + 1;
isCreating.value = true;
selectedKey.value = null;
draft.value = {
id: `m-${Date.now().toString(36)}`,
parentId: null,
title: state.locale.value === 'zh' ? '新菜单' : 'New menu',
icon: 'square',
route: '',
sort: nextSort,
visible: true,
};
}
function cancelCreate() {
isCreating.value = false;
selectedKey.value = menus.value[0]?.id ?? null;
}
</script>
<template>
<div class="adm-page">
<p class="adm-page__hint">{{ t.menu_hint }}</p>
<CfSplitter orientation="horizontal" :default-size="40" unit="%" class="adm-menus">
<template #start>
<div class="adm-menus__tree">
<header class="adm-menus__tree-head">
<CfButton size="sm" variant="primary" @click="openCreate">
+ {{ state.locale.value === 'zh' ? '新增菜单' : 'New menu' }}
</CfButton>
<CfButton
size="sm"
variant="danger"
:disabled="!selectedKey || isCreating"
@click="askDelete"
>
{{ t.delete }}
</CfButton>
</header>
<div class="adm-menus__tree-body">
<CfTreeView
:nodes="treeNodes"
selectable="single"
:selected-key="selectedKey"
v-model:expanded-keys="expandedKeys"
:show-line="true"
size="sm"
@update:selectedKey="(k) => (selectedKey = k ?? null)"
/>
</div>
</div>
</template>
<template #end>
<div class="adm-menus__editor">
<template v-if="draft">
<header class="adm-menus__editor-head">
<h3>{{ isCreating ? (state.locale.value === 'zh' ? '新增菜单' : 'New menu') : (draft.title || '—') }}</h3>
<span class="adm-menus__editor-meta">{{ draft.id }}</span>
</header>
<CfForm :model="draft" layout="vertical">
<CfFormField :label="t.menu_title" name="title">
<CfInput v-model="draft.title" />
</CfFormField>
<CfFormField :label="t.menu_icon" name="icon">
<CfIconPicker v-model="draft.icon" :placeholder="t.menu_icon" />
<div v-if="draft.icon" class="adm-menus__preview">
<CfIcon :name="draft.icon" />
<code>{{ draft.icon }}</code>
</div>
</CfFormField>
<CfFormField :label="t.menu_route" name="route">
<CfInput v-model="draft.route" placeholder="/example" />
</CfFormField>
<div class="adm-menus__row">
<CfFormField :label="t.menu_parent" name="parentId" style="flex: 1;">
<CfTreeSelect
v-model="parentSelectValue"
:options="parentTreeOptions"
searchable
size="sm"
:placeholder="t.menu_parent"
/>
</CfFormField>
<CfFormField :label="t.menu_sort" name="sort" style="width: 120px;">
<CfNumberInput v-model="draft.sort" :min="1" :step="1" />
</CfFormField>
</div>
<CfFormField :label="t.menu_visible" name="visible">
<CfSwitch v-model="draft.visible" />
</CfFormField>
</CfForm>
<footer class="adm-menus__editor-foot">
<CfButton v-if="isCreating" variant="tertiary" @click="cancelCreate">{{ t.cancel }}</CfButton>
<CfButton variant="primary" @click="saveDraft">{{ t.save }}</CfButton>
</footer>
</template>
<CfEmpty v-else :description="t.menu_select_to_edit" />
</div>
</template>
</CfSplitter>
<CfConfirmDialog
v-model:open="confirmOpen"
tone="warning"
:title="state.locale.value === 'zh' ? '确认删除?' : 'Delete this menu?'"
:description="state.locale.value === 'zh' ? '将连带删除所有子菜单。' : 'All descendant menu entries will be removed.'"
:ok-text="t.delete"
:cancel-text="t.cancel"
ok-variant="danger"
@ok="confirmDelete"
/>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-page__hint { margin: 0; color: var(--fg-3); font-size: var(--t-12); }
.adm-menus { min-height: 480px; }
.adm-menus__tree {
height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-4);
padding: 10px;
}
.adm-menus__tree-head {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.adm-menus__tree-body {
flex: 1;
overflow: auto;
}
.adm-menus__editor {
height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
padding-left: 12px;
}
.adm-menus__editor-head {
display: flex;
align-items: baseline;
justify-content: space-between;
border-bottom: 1px solid var(--line-1);
padding-bottom: 8px;
}
.adm-menus__editor-head h3 {
margin: 0;
font-size: var(--t-14);
font-weight: var(--w-medium);
color: var(--fg-1);
}
.adm-menus__editor-meta {
color: var(--fg-3);
font-family: var(--font-mono);
font-size: var(--t-11);
}
.adm-menus__editor-foot {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 8px;
border-top: 1px solid var(--line-1);
}
.adm-menus__row {
display: flex;
gap: 12px;
}
.adm-menus__preview {
margin-top: 8px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--line-1);
border-radius: var(--r-4);
background: var(--bg-2);
color: var(--fg-2);
font-size: var(--t-12);
}
.adm-menus__preview code { font-family: var(--font-mono); font-size: var(--t-11); }
</style> <script setup>
/**
* admin-mini · 实时演示。
* 顶部 AdminHeader(搜索 / 消息铃铛 / 语言 / 主题齿轮 / 用户头像下拉);
* 中间 CfAppShell + CfSidebar / CfNavMenu + CfBreadcrumb;
* 右抽屉 SettingsDrawer 装主题/密度/菜单/主色;个人中心 + 修改密码用 Modal;
* 7 个子页面随菜单切换。
*/
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
import { CfAppShell, CfSidebar, CfNavMenu, CfBreadcrumb, CfCommandPalette, CfTour, toast } from '@chufix-design/vue';
import AdminHeader from './AdminHeader.vue';
import Login from './Login.vue';
import SettingsDrawer from './SettingsDrawer.vue';
import ProfileModal from './ProfileModal.vue';
import ChangePasswordModal from './ChangePasswordModal.vue';
import Dashboard from './pages/Dashboard.vue';
import Users from './pages/Users.vue';
import Roles from './pages/Roles.vue';
import UserRoles from './pages/UserRoles.vue';
import Dict from './pages/Dict.vue';
import OperationLog from './pages/OperationLog.vue';
import LoginLog from './pages/LoginLog.vue';
import SystemSettings from './pages/SystemSettings.vue';
import Org from './pages/Org.vue';
import Menus from './pages/Menus.vue';
import {
ACCENT_HUE,
DemoStateKey,
STRINGS,
} from './state';
/* 默认主题尽量跟宿主页面一致:SSR 时先放 dark-cool 占位,hydrate 后用 onMounted 读取宿主 <html> / <body> 上的 data-theme 覆盖。 */
const theme = ref<DemoTheme>('dark-cool');
const density = ref<DemoDensity>('comfortable');
const menuForm = ref<DemoMenuForm>('sidebar');
const accent = ref<DemoAccent>('blue');
const locale = ref<DemoLocale>('zh');
const route = ref<RouteId>('dashboard');
provide(DemoStateKey, { theme, density, menuForm, accent, locale });
const t = computed(() => STRINGS[locale.value]);
// 顶部交互入口
const settingsOpen = ref(false);
const profileOpen = ref(false);
const passwordOpen = ref(false);
const paletteOpen = ref(false);
const tourOpen = ref(false);
/* ---------- 登录态 ---------- */
const AUTH_KEY = 'chufix-tpl:admin-mini:auth';
const authedUser = ref<string | null>(null);
function onLoginSuccess(payload: { username: string; remember: boolean }) {
authedUser.value = payload.username;
try {
if (payload.remember) window.sessionStorage.setItem(AUTH_KEY, payload.username);
else window.sessionStorage.removeItem(AUTH_KEY);
} catch { /* storage blocked */ }
toast.success(t.value.login_welcome.replace('{user}', payload.username));
// 首次登录后弹出引导(同一 session 只弹一次)
try {
if (!window.sessionStorage.getItem('chufix-tpl:admin-mini:tour-done')) {
setTimeout(() => (tourOpen.value = true), 600);
window.sessionStorage.setItem('chufix-tpl:admin-mini:tour-done', '1');
}
} catch { /* */ }
}
function onLogout() {
authedUser.value = null;
try { window.sessionStorage.removeItem(AUTH_KEY); } catch { /* */ }
toast.info(t.value.logout_done);
}
/* ---------- 全局快捷键:Ctrl/⌘ + K 打开 command palette ---------- */
function onGlobalKey(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
paletteOpen.value = !paletteOpen.value;
}
}
/* ---------- 主题:从宿主页面继承默认值;把当前选择同步到 body 让 Teleport 出去的弹层(Modal / Drawer / Tour / Popover)也继承同一套主题。 ---------- */
const HOST_THEME_BACKUP_KEY = '__chufix_admin_mini_theme_backup__';
function applyToBody() {
if (typeof document === 'undefined') return;
const body = document.body;
body.setAttribute('data-theme', theme.value);
body.setAttribute('data-density', density.value);
body.style.setProperty('--accent-1', ACCENT_HUE[accent.value]);
}
function restoreHostTheme() {
if (typeof document === 'undefined') return;
const body = document.body;
const backup = (window)[HOST_THEME_BACKUP_KEY] as
| { theme: string | null; density: string | null; accent: string }
| undefined;
if (!backup) return;
if (backup.theme !== null) body.setAttribute('data-theme', backup.theme);
else body.removeAttribute('data-theme');
if (backup.density !== null) body.setAttribute('data-density', backup.density);
else body.removeAttribute('data-density');
if (backup.accent) body.style.setProperty('--accent-1', backup.accent);
else body.style.removeProperty('--accent-1');
delete (window)[HOST_THEME_BACKUP_KEY];
}
onMounted(() => {
if (typeof window !== 'undefined') {
window.addEventListener('keydown', onGlobalKey);
// 备份宿主主题,以便卸载时还原
const body = document.body;
(window)[HOST_THEME_BACKUP_KEY] = {
theme: body.getAttribute('data-theme'),
density: body.getAttribute('data-density'),
accent: body.style.getPropertyValue('--accent-1') || '',
};
// 用宿主主题作为初始值(如果是已知值),否则保留组件默认 dark-cool
const hostTheme = body.getAttribute('data-theme') ?? document.documentElement.getAttribute('data-theme');
if (hostTheme === 'dark-cool' || hostTheme === 'dark-warm' || hostTheme === 'light' || hostTheme === 'dark') {
theme.value = hostTheme === 'dark' ? 'dark-cool' : (hostTheme);
}
const hostDensity = body.getAttribute('data-density');
if (hostDensity === 'comfortable' || hostDensity === 'compact') {
density.value = hostDensity;
}
applyToBody();
// 恢复登录态(同一标签页刷新不需要重新登录)
try {
const saved = window.sessionStorage.getItem(AUTH_KEY);
if (saved) authedUser.value = saved;
} catch { /* */ }
// 首次访问自动启动 tour,sessionStorage 标志防止反复弹(仅登录后才弹)
try {
if (authedUser.value && !window.sessionStorage.getItem('chufix-tpl:admin-mini:tour-done')) {
setTimeout(() => (tourOpen.value = true), 400);
window.sessionStorage.setItem('chufix-tpl:admin-mini:tour-done', '1');
}
} catch { /* sessionStorage blocked */ }
}
});
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', onGlobalKey);
restoreHostTheme();
}
});
watch([theme, density, accent], applyToBody);
const tourSteps = computed<TourStep[]>(() => [
{ target: '[data-tour="brand"]', title: t.value.tour_brand_title, description: t.value.tour_brand_desc, placement: 'bottom' },
{ target: '[data-tour="search"]', title: t.value.tour_search_title, description: t.value.tour_search_desc, placement: 'bottom' },
{ target: '[data-tour="settings"]', title: t.value.tour_settings_title, description: t.value.tour_settings_desc, placement: 'bottom' },
{ target: '[data-tour="user"]', title: t.value.tour_user_title, description: t.value.tour_user_desc, placement: 'bottom' },
]);
const sidebarItems = computed<SidebarEntry[]>(() => [
{
type: 'group',
label: t.value.grp_overview,
items: [
{ key: 'dashboard', label: t.value.page_dashboard, icon: iconSvg('M3 13h6V3H3v10zm0 8h6v-6H3v6zm8 0h10V11H11v10zm0-18v6h10V3H11z') },
],
},
{
type: 'group',
label: t.value.grp_authz,
items: [
{ key: 'users', label: t.value.page_users, icon: iconSvg('M12 12a4 4 0 100-8 4 4 0 000 8zm-8 9a8 8 0 0116 0H4z') },
{ key: 'roles', label: t.value.page_roles, icon: iconSvg('M12 2l9 4v6c0 5-3.5 9-9 10-5.5-1-9-5-9-10V6l9-4z') },
{ key: 'user-roles', label: t.value.page_user_roles, icon: iconSvg('M9 12a4 4 0 100-8 4 4 0 000 8zm6 0a3 3 0 100-6 3 3 0 000 6zm-6 2c-3 0-9 1.5-9 4.5V21h12v-2.5c0-1.6 1.7-2.7 3.5-3.3-.6-.7-1.7-1.2-3-1.2zm6 .5c2 0 6 1 6 3V21h-6v-3.5z') },
{ key: 'org', label: t.value.page_org, icon: iconSvg('M3 21V8h6V3h6v5h6v13H3zm2-2h4v-3H5v3zm6 0h4v-3h-4v3zm6 0h4v-3h-4v3zM5 14h4v-3H5v3zm6 0h4v-3h-4v3zm6 0h4v-3h-4v3zM11 8h4V5h-4v3z') },
],
},
{
type: 'group',
label: t.value.grp_system,
items: [
{ key: 'dict', label: t.value.page_dict, icon: iconSvg('M4 4h16v3H4zM4 10h16v3H4zM4 16h16v3H4z') },
{ key: 'op-log', label: t.value.page_op_log, icon: iconSvg('M5 3h11l3 3v15H5z M14 3v4h4') },
{ key: 'login-log', label: t.value.page_login_log, icon: iconSvg('M10 17l5-5-5-5v3H3v4h7v3z M21 3h-8v18h8V3z') },
{ key: 'menus', label: t.value.page_menus, icon: iconSvg('M3 5h6v6H3zm0 8h6v6H3zm10-8h6v6h-6zm0 8h6v6h-6z') },
{ key: 'sys-settings', label: t.value.page_sys_settings, icon: iconSvg('M12 8a4 4 0 100 8 4 4 0 000-8zm9.4 4a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09a1.65 1.65 0 00-1-1.51 1.65 1.65 0 00-1.82.33l-.06.06A2 2 0 113.39 16.96l.06-.06a1.65 1.65 0 00.33-1.82A1.65 1.65 0 002.27 14H3a2 2 0 110-4h-.09c.36 0 .68-.13 1-.34A1.65 1.65 0 003.78 8 1.65 1.65 0 003.45 6.18l-.06-.06a2 2 0 112.83-2.83l.06.06c.5.5 1.24.63 1.82.33H8c.36 0 .68-.13 1-.34A1.65 1.65 0 0010 2.27V3a2 2 0 114 0v-.09c0 .36.13.68.34 1A1.65 1.65 0 0016 3.78a1.65 1.65 0 011.82.33l.06.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0021.4 9z') },
],
},
]);
const navMenuItems = computed(() => {
const groups= sidebarItems.value;
const out= [];
for (const g of groups) {
if ('type' in g && g.type === 'group' && g.items) {
for (const item of g.items) {
out.push({ key: item.key, label: item.label, href: '#' + item.key });
}
}
}
return out;
});
const breadcrumbItems = computed(() => {
const item = flatItem(route.value);
const groupLabel = findGroupLabel(route.value);
const items= [{ label: t.value.brand }];
if (groupLabel) items.push({ label: groupLabel });
if (item) items.push({ label: item.label });
return items;
});
function flatItem(key): SidebarItem | null {
for (const g of sidebarItems.value) {
if ('type' in g && g.type === 'group') {
const found = g.items.find((i) => i.key === key);
if (found) return found;
}
}
return null;
}
function findGroupLabel(key): string | undefined {
for (const g of sidebarItems.value) {
if ('type' in g && g.type === 'group') {
if (g.items.some((i) => i.key === key)) return g.label;
}
}
return undefined;
}
function iconSvg(d) {
return `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
}
const shellStyle = computed(() => ({
'--accent-1': ACCENT_HUE[accent.value],
}));
const showSidebar = computed(() => menuForm.value !== 'topbar');
const sidebarCollapsed = computed(() => menuForm.value === 'collapsed');
const paletteItems = computed<CommandPaletteItem[]>(() => {
const navs= [];
for (const g of sidebarItems.value) {
if ('type' in g && g.type === 'group') {
for (const item of g.items) {
navs.push({
id: `nav:${item.key}`,
label: item.label,
group: t.value.cmd_navigate,
keywords: [item.key],
});
}
}
}
return [
...navs,
{ id: 'act:settings', label: t.value.cmd_open_settings, group: t.value.cmd_actions, shortcut: '⇧S' },
{ id: 'act:profile', label: t.value.cmd_open_profile, group: t.value.cmd_actions },
{ id: 'act:password', label: t.value.cmd_change_password, group: t.value.cmd_actions },
{ id: 'act:logout', label: t.value.cmd_logout, group: t.value.cmd_actions },
];
});
function onPaletteSelect(id) {
paletteOpen.value = false;
if (id.startsWith('nav:')) {
route.value = id.slice(4);
} else if (id === 'act:settings') settingsOpen.value = true;
else if (id === 'act:profile') profileOpen.value = true;
else if (id === 'act:password') passwordOpen.value = true;
else if (id === 'act:logout') onLogout();
}
const pageComp = computed(() => {
switch (route.value) {
case 'dashboard': return Dashboard;
case 'users': return Users;
case 'roles': return Roles;
case 'user-roles': return UserRoles;
case 'org': return Org;
case 'dict': return Dict;
case 'op-log': return OperationLog;
case 'login-log': return LoginLog;
case 'menus': return Menus;
case 'sys-settings': return SystemSettings;
default: return Dashboard;
}
});
</script>
<template>
<div
class="adm-root"
:data-theme="theme"
:data-density="density"
:style="shellStyle"
>
<Login v-if="!authedUser" @login-success="onLoginSuccess" />
<template v-else>
<CfAppShell
:sidebar-collapsed="sidebarCollapsed"
:sidebar-width="sidebarCollapsed ? 64 : 220"
:header-height="56"
>
<template #header>
<AdminHeader
@open-settings="settingsOpen = true"
@open-profile="profileOpen = true"
@open-change-password="passwordOpen = true"
@logout="onLogout"
/>
</template>
<template v-if="showSidebar" #sidebar>
<CfSidebar
:items="sidebarItems"
:model-value="route"
:collapsed="sidebarCollapsed"
@update:modelValue="(key) => (route = key)"
/>
</template>
<section class="adm-body">
<CfNavMenu
v-if="menuForm === 'topbar'"
:items="navMenuItems"
:active="route"
variant="underline"
class="adm-body__nav"
@navigate="(item) => (route = item.key)"
/>
<CfBreadcrumb :items="breadcrumbItems" />
<h2 class="adm-body__title">{{ flatItem(route)?.label ?? '' }}</h2>
<component :is="pageComp" :key="route" />
</section>
</CfAppShell>
<SettingsDrawer v-model:open="settingsOpen" />
<ProfileModal v-model:open="profileOpen" />
<ChangePasswordModal v-model:open="passwordOpen" />
<CfCommandPalette
:open="paletteOpen"
:items="paletteItems"
:placeholder="t.cmd_placeholder"
:empty-text="t.cmd_empty"
@update:open="(v) => (paletteOpen = v)"
@select="onPaletteSelect"
/>
<CfTour
v-model="tourOpen"
:steps="tourSteps"
/>
</template>
</div>
</template>
<style scoped>
.adm-root {
display: flex;
flex-direction: column;
background: var(--bg-0);
color: var(--fg-1);
font-family: var(--font-sans);
height: 100%;
min-height: 720px;
isolation: isolate;
border-radius: var(--r-6);
overflow: hidden;
border: 1px solid var(--line-1);
}
.adm-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px 20px 24px;
}
.adm-body__title {
margin: 0;
font-size: var(--t-18);
font-weight: var(--w-medium);
color: var(--fg-1);
}
</style> <script setup>
/**
* admin-mini · 顶部 header。
* 左:品牌;中:搜索;右:消息铃铛 / 语言 / 设置齿轮(→ 右抽屉)/ 用户头像(→ 下拉菜单)。
* 通过 emit 把交互上抛给 AdminMiniDemo 父组件统一处理。
*/
import { computed, inject, ref } from 'vue';
import {
CfSearchInput,
CfPopover,
CfDropdown,
CfAvatar,
CfBadge,
CfTag,
} from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from './state';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const emit = defineEmits<{
(e: 'open-settings'): void;
(e: 'open-profile'): void;
(e: 'open-change-password'): void;
(e: 'logout'): void;
}>();
const search = ref('');
// 通知 mock 数据,演示用
const notifications = ref<NotifItem[]>([
{ id: 1, title: '用户 ada 提交了新角色申请', from: 'system', at: '2 分钟前', unread: true },
{ id: 2, title: '操作日志:grace 导出了登录日志', from: 'audit', at: '8 分钟前', unread: true },
{ id: 3, title: '字典「日志级别」新增 DEBUG 项', from: 'admin', at: '32 分钟前', unread: false },
{ id: 4, title: '今日凌晨完成数据库自动备份', from: 'cron', at: '昨天', unread: false },
]);
const unreadCount = computed(() => notifications.value.filter((n) => n.unread).length);
function markAllRead() {
notifications.value = notifications.value.map((n) => ({ ...n, unread: false }));
}
const userMenuItems = computed<DropdownItem[]>(() => [
{ key: 'user-info', label: 'Admin · [email protected]', header: true },
{ key: 'divider-1', divider: true },
{ key: 'profile', label: t.value.profile },
{ key: 'change-password', label: t.value.change_password },
{ key: 'divider-2', divider: true },
{ key: 'logout', label: t.value.logout, tone: 'danger' },
]);
function onUserMenu(item) {
switch (item.key) {
case 'profile': emit('open-profile'); break;
case 'change-password': emit('open-change-password'); break;
case 'logout': emit('logout'); break;
}
}
function toggleLocale() {
state.locale.value = state.locale.value === 'zh' ? 'en' : 'zh';
}
</script>
<template>
<div class="adm-header">
<div class="adm-header__brand" data-tour="brand">
<span class="adm-header__logo" />
<span class="adm-header__title">{{ t.brand }}</span>
<CfTag size="sm" tone="info" variant="soft">demo</CfTag>
</div>
<div class="adm-header__center" data-tour="search">
<CfSearchInput
v-model="search"
:placeholder="t.search_placeholder"
size="sm"
/>
</div>
<div class="adm-header__actions">
<CfPopover placement="bottom" :width="320">
<button class="adm-iconbtn" type="button" :aria-label="t.notifications">
<CfBadge :content="unreadCount" :max="99" :dot="false" :show-zero="false" placement="top-right">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8a6 6 0 1112 0c0 6 3 7 3 7H3s3-1 3-7" />
<path d="M10 19a2 2 0 004 0" />
</svg>
</CfBadge>
</button>
<template #content>
<div class="adm-notif">
<header class="adm-notif__head">
<span class="adm-notif__title">{{ t.notifications }}</span>
<button type="button" class="adm-notif__link" @click="markAllRead">
{{ t.mark_all_read }}
</button>
</header>
<ul v-if="notifications.length" class="adm-notif__list">
<li
v-for="n in notifications"
:key="n.id"
class="adm-notif__item"
:class="{ 'is-unread': n.unread }"
>
<span class="adm-notif__dot" />
<div class="adm-notif__body">
<div class="adm-notif__text">{{ n.title }}</div>
<div class="adm-notif__meta">{{ n.from }} · {{ n.at }}</div>
</div>
</li>
</ul>
<div v-else class="adm-notif__empty">{{ t.no_notifications }}</div>
<footer class="adm-notif__foot">
<a href="#" @click.prevent>{{ t.view_all }}</a>
</footer>
</div>
</template>
</CfPopover>
<button class="adm-iconbtn adm-iconbtn--lang" type="button" :aria-label="t.switch_locale" @click="toggleLocale">
{{ state.locale.value === 'zh' ? '中' : 'EN' }}
</button>
<button class="adm-iconbtn" type="button" :aria-label="t.settings" data-tour="settings" @click="emit('open-settings')">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09a1.65 1.65 0 00-1-1.51 1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09a1.65 1.65 0 001.51-1 1.65 1.65 0 00-.33-1.82l-.06-.06A2 2 0 017.04 4.04l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9c.36.94 1.18 1.5 2.15 1.51H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
</button>
<CfDropdown :items="userMenuItems" placement="bottom" :width="200" @select="onUserMenu">
<button class="adm-user" type="button" data-tour="user">
<CfAvatar name="Admin" size="sm" />
<span class="adm-user__name">Admin</span>
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6l4 4 4-4" />
</svg>
</button>
</CfDropdown>
</div>
</div>
</template>
<style scoped>
.adm-header {
display: flex;
align-items: center;
gap: 16px;
padding: 0 16px;
height: 100%;
width: 100%;
}
.adm-header__brand {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.adm-header__logo {
width: 22px;
height: 22px;
border-radius: var(--r-4);
background: linear-gradient(135deg, var(--accent-1), color-mix(in oklch, var(--accent-1), var(--bg-0) 35%));
box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--accent-1), transparent 50%);
}
.adm-header__title {
font-weight: var(--w-medium);
color: var(--fg-1);
white-space: nowrap;
}
.adm-header__center {
width: 320px;
max-width: 36%;
}
.adm-header__center :deep(.cf-searchinput) {
width: 100%;
}
.adm-header__actions {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.adm-iconbtn {
width: 32px;
height: 32px;
border-radius: var(--r-4);
border: 1px solid transparent;
background: transparent;
color: var(--fg-2);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: background var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out);
}
.adm-iconbtn:hover { background: var(--bg-2); color: var(--fg-1); }
.adm-iconbtn--lang {
width: auto;
padding: 0 10px;
font-size: var(--t-12);
font-weight: var(--w-medium);
border: 1px solid var(--line-1);
}
.adm-user {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 8px 4px 4px;
border-radius: var(--r-pill);
border: 1px solid transparent;
background: transparent;
color: var(--fg-1);
cursor: pointer;
transition: background var(--dur-fast) var(--ease-out);
}
.adm-user:hover { background: var(--bg-2); }
.adm-user__name {
font-size: var(--t-12);
font-weight: var(--w-medium);
}
.adm-notif {
background: var(--bg-1);
border-radius: var(--r-4);
min-width: 280px;
}
.adm-notif__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--line-1);
}
.adm-notif__title {
font-weight: var(--w-medium);
color: var(--fg-1);
font-size: var(--t-13);
}
.adm-notif__link {
background: transparent;
border: 0;
color: var(--accent-1);
font-size: var(--t-11);
cursor: pointer;
padding: 0;
}
.adm-notif__list {
margin: 0;
padding: 0;
list-style: none;
max-height: 280px;
overflow: auto;
}
.adm-notif__item {
display: grid;
grid-template-columns: 16px 1fr;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--line-1);
}
.adm-notif__item:last-child { border-bottom: 0; }
.adm-notif__dot {
width: 6px;
height: 6px;
margin-top: 6px;
border-radius: 50%;
background: transparent;
border: 1px solid var(--line-2);
}
.adm-notif__item.is-unread .adm-notif__dot {
background: var(--accent-1);
border-color: var(--accent-1);
}
.adm-notif__text {
color: var(--fg-1);
font-size: var(--t-12);
line-height: 1.5;
}
.adm-notif__meta {
margin-top: 2px;
color: var(--fg-3);
font-size: var(--t-11);
}
.adm-notif__empty {
padding: 28px 16px;
text-align: center;
color: var(--fg-3);
font-size: var(--t-12);
}
.adm-notif__foot {
padding: 8px 12px;
text-align: center;
border-top: 1px solid var(--line-1);
}
.adm-notif__foot a {
color: var(--accent-1);
font-size: var(--t-12);
}
</style> <script setup>
/**
* admin-mini 登录页 —— 演示态,无真正后端。
* 通过 admin / admin 通过校验后 emit login-success 给 AdminMiniDemo。
* 用 ChuFix 组件:CfInput / CfButton / CfCheckbox / CfAlert / CfTag。
*/
import { computed, inject, onMounted, ref } from 'vue';
import { CfInput, CfButton, CfCheckbox, CfAlert, CfTag } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from './state';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const emit = defineEmits<{
(e: 'login-success', payload: { username: string; remember: boolean }): void;
}>();
const username = ref('admin');
const password = ref('admin');
const remember = ref(true);
const loading = ref(false);
const error = ref('');
const usernameRef = ref<InstanceType<typeof CfInput> | null>(null);
onMounted(() => {
// 焦点交给账号输入框,更符合习惯
setTimeout(() => {
(usernameRef.value{ focus?: () => void } | null)?.focus?.();
}, 100);
});
function submit() {
error.value = '';
if (!username.value.trim() || !password.value) {
error.value = t.value.login_invalid;
return;
}
loading.value = true;
// 演示态:800ms 假等待,让 loading 状态肉眼可见
setTimeout(() => {
loading.value = false;
if (username.value.trim() === 'admin' && password.value === 'admin') {
emit('login-success', { username: username.value.trim(), remember: remember.value });
} else {
error.value = t.value.login_invalid;
}
}, 600);
}
function onKey(e) {
if (e.key === 'Enter') submit();
}
</script>
<template>
<div class="adm-login" :data-theme="state.theme.value" :data-density="state.density.value">
<aside class="adm-login__brand">
<div class="adm-login__brand-card">
<div class="adm-login__logo" aria-hidden="true">
<svg viewBox="0 0 32 32" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 10 L16 4 L26 10 L26 22 L16 28 L6 22 Z" />
<path d="M11 13 L16 10 L21 13 L21 19 L16 22 L11 19 Z" opacity="0.6" />
</svg>
</div>
<h1 class="adm-login__title">{{ t.brand }}</h1>
<p class="adm-login__lede">{{ t.login_lede }}</p>
<ul class="adm-login__features">
<li>
<CfTag size="sm" tone="primary" variant="soft">Vue / React</CfTag>
<span>{{ state.locale.value === 'zh' ? '双框架同源' : 'Same source for both frameworks' }}</span>
</li>
<li>
<CfTag size="sm" tone="success" variant="soft">CRUD</CfTag>
<span>{{ state.locale.value === 'zh' ? '用户 / 角色 / 字典 / 菜单 全流程' : 'Users / roles / dictionary / menus end-to-end' }}</span>
</li>
<li>
<CfTag size="sm" tone="info" variant="soft">Theme</CfTag>
<span>{{ state.locale.value === 'zh' ? '三主题、两密度、五主色' : 'Three themes, two densities, five accents' }}</span>
</li>
</ul>
</div>
</aside>
<main class="adm-login__panel">
<div class="adm-login__form" @keydown="onKey">
<header class="adm-login__form-head">
<h2>{{ t.login_title }}</h2>
<p>{{ t.login_hint }}</p>
</header>
<CfAlert
v-if="error"
tone="danger"
variant="soft"
:title="error"
closable
@close="error = ''"
/>
<div class="adm-login__field">
<label class="adm-login__label">{{ t.login_username }}</label>
<CfInput
ref="usernameRef"
v-model="username"
:placeholder="t.login_username"
size="lg"
/>
</div>
<div class="adm-login__field">
<label class="adm-login__label">{{ t.login_password }}</label>
<CfInput
v-model="password"
type="password"
:placeholder="t.login_password"
size="lg"
/>
</div>
<div class="adm-login__row">
<label class="adm-login__check">
<CfCheckbox v-model="remember" />
<span>{{ t.login_remember }}</span>
</label>
<a class="adm-login__link" href="#" @click.prevent>
{{ state.locale.value === 'zh' ? '忘记密码' : 'Forgot password?' }}
</a>
</div>
<CfButton
variant="primary"
size="lg"
block
:loading="loading"
@click="submit"
>
{{ t.login_submit }}
</CfButton>
<p class="adm-login__demo">{{ t.login_demo_account }}</p>
</div>
</main>
</div>
</template>
<style scoped>
.adm-login {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 100%;
height: 100%;
background: var(--bg-0);
color: var(--fg-1);
font-family: var(--font-sans);
isolation: isolate;
}
.adm-login__brand {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background:
radial-gradient(circle at 80% 20%, color-mix(in oklch, var(--accent-1), transparent 70%), transparent 55%),
radial-gradient(circle at 10% 80%, color-mix(in oklch, var(--accent-1), transparent 80%), transparent 60%),
linear-gradient(135deg, color-mix(in oklch, var(--accent-1), var(--bg-0) 60%), var(--bg-0));
}
.adm-login__brand-card {
max-width: 360px;
display: flex;
flex-direction: column;
gap: 14px;
}
.adm-login__logo {
width: 40px;
height: 40px;
border-radius: var(--r-4);
background: linear-gradient(135deg, var(--accent-1), color-mix(in oklch, var(--accent-1), var(--bg-0) 40%));
box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent-1), transparent 50%) inset;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--fg-on-accent, #fff);
}
.adm-login__title {
margin: 0;
font-size: var(--t-22);
font-weight: var(--w-medium);
color: var(--fg-1);
}
.adm-login__lede {
margin: 0;
color: var(--fg-2);
font-size: var(--t-14);
line-height: 1.6;
}
.adm-login__features {
margin: 12px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.adm-login__features li {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--fg-2);
font-size: var(--t-12);
}
.adm-login__panel {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 32px;
}
.adm-login__form {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: 14px;
}
.adm-login__form-head h2 {
margin: 0 0 4px;
font-size: var(--t-18);
font-weight: var(--w-medium);
color: var(--fg-1);
}
.adm-login__form-head p {
margin: 0;
color: var(--fg-3);
font-size: var(--t-12);
}
.adm-login__field {
display: flex;
flex-direction: column;
gap: 6px;
}
.adm-login__field :deep(.cf-input) {
max-width: none;
width: 100%;
}
.adm-login__label {
font-size: var(--t-12);
color: var(--fg-2);
}
.adm-login__row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 2px;
}
.adm-login__check {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: var(--t-12);
color: var(--fg-2);
cursor: pointer;
}
.adm-login__link {
color: var(--accent-1);
font-size: var(--t-12);
text-decoration: none;
}
.adm-login__link:hover { text-decoration: none; opacity: 0.85; }
.adm-login__demo {
margin: 0;
text-align: center;
color: var(--fg-3);
font-size: var(--t-11);
padding-top: 10px;
border-top: 1px dashed var(--line-1);
}
@media (max-width: 820px) {
.adm-login {
grid-template-columns: 1fr;
}
.adm-login__brand {
display: none;
}
}
</style> <script setup>
/**
* 右侧抽屉:主题 / 密度 / 菜单形态 / 主色 设置面板。
* 替代之前平铺在最顶上的 toolbar,让外壳更像真实后台。
*/
import { computed, inject } from 'vue';
import { CfDrawer, CfSegmentedControl } from '@chufix-design/vue';
import {
DemoStateKey,
STRINGS,
} from './state';
const state = inject(DemoStateKey)!;
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'update:open', value): void }>();
const t = computed(() => STRINGS[state.locale.value]);
const themeOpts = computed<{ value: DemoTheme; label: string }[]>(() => [
{ value: 'dark-cool', label: t.value.theme_dark_cool },
{ value: 'dark-warm', label: t.value.theme_dark_warm },
{ value: 'light', label: t.value.theme_light },
]);
const densityOpts = computed<{ value: DemoDensity; label: string }[]>(() => [
{ value: 'comfortable', label: t.value.density_comfortable },
{ value: 'compact', label: t.value.density_compact },
]);
const menuOpts = computed<{ value: DemoMenuForm; label: string }[]>(() => [
{ value: 'sidebar', label: t.value.menu_sidebar },
{ value: 'topbar', label: t.value.menu_topbar },
{ value: 'collapsed', label: t.value.menu_collapsed },
]);
const accentOpts= [
{ value: 'blue', color: 'oklch(64% 0.16 263)' },
{ value: 'green', color: 'oklch(68% 0.16 150)' },
{ value: 'purple', color: 'oklch(64% 0.18 300)' },
{ value: 'orange', color: 'oklch(72% 0.16 60)' },
{ value: 'rose', color: 'oklch(66% 0.18 15)' },
];
</script>
<template>
<CfDrawer
:open="props.open"
placement="right"
size="sm"
:title="t.settings"
:show-close="true"
:mask="true"
@update:open="(v) => emit('update:open', v)"
>
<div class="adm-settings">
<section class="adm-settings__group">
<h4 class="adm-settings__label">{{ t.switch_theme }}</h4>
<CfSegmentedControl
:model-value="state.theme.value"
:items="themeOpts"
size="sm"
@update:modelValue="(v) => (state.theme.value = v)"
/>
</section>
<section class="adm-settings__group">
<h4 class="adm-settings__label">{{ t.switch_density }}</h4>
<CfSegmentedControl
:model-value="state.density.value"
:items="densityOpts"
size="sm"
@update:modelValue="(v) => (state.density.value = v)"
/>
</section>
<section class="adm-settings__group">
<h4 class="adm-settings__label">{{ t.switch_menu }}</h4>
<CfSegmentedControl
:model-value="state.menuForm.value"
:items="menuOpts"
size="sm"
@update:modelValue="(v) => (state.menuForm.value = v)"
/>
</section>
<section class="adm-settings__group">
<h4 class="adm-settings__label">{{ t.switch_accent }}</h4>
<div class="adm-settings__accents">
<button
v-for="o in accentOpts"
:key="o.value"
type="button"
class="adm-settings__dot"
:class="{ 'is-active': state.accent.value === o.value }"
:style="{ background: o.color }"
:aria-label="o.value"
:aria-pressed="state.accent.value === o.value"
@click="state.accent.value = o.value"
/>
</div>
</section>
</div>
</CfDrawer>
</template>
<style scoped>
.adm-settings {
display: flex;
flex-direction: column;
gap: 18px;
padding: 4px;
}
.adm-settings__group {
display: flex;
flex-direction: column;
gap: 8px;
}
.adm-settings__label {
margin: 0;
font-size: var(--t-11);
font-weight: var(--w-medium);
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--fg-3);
}
.adm-settings__accents {
display: inline-flex;
gap: 8px;
}
.adm-settings__dot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid var(--line-1);
cursor: pointer;
transition: transform var(--dur-fast) var(--ease-out);
padding: 0;
}
.adm-settings__dot:hover { transform: scale(1.1); }
.adm-settings__dot.is-active {
outline: 2px solid var(--fg-1);
outline-offset: 2px;
}
</style> <script setup>
/** 个人中心 modal:账号基本信息(演示态,本地保存)。 */
import { computed, inject, ref, watch } from 'vue';
import { CfModal, CfForm, CfFormField, CfInput, CfSelect, CfAvatar, toast } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from './state';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'update:open', value): void }>();
const form = ref({
username: 'admin',
name: '系统管理员',
email: '[email protected]',
phone: '13800000001',
role: '超级管理员',
});
// 每次开启时重置(演示用,无后端持久化)
watch(() => props.open, (v) => {
if (v) {
form.value = {
username: 'admin',
name: '系统管理员',
email: '[email protected]',
phone: '13800000001',
role: '超级管理员',
};
}
});
function save() {
toast.success(t.value.profile_saved);
emit('update:open', false);
}
</script>
<template>
<CfModal
:open="props.open"
:title="t.profile"
size="md"
:ok-text="t.save"
:cancel-text="t.cancel"
:on-before-ok="() => { save(); return true; }"
@update:open="(v) => emit('update:open', v)"
>
<div class="adm-profile">
<div class="adm-profile__avatar">
<CfAvatar name="Admin" size="xl" />
<div class="adm-profile__name">
<strong>{{ form.name }}</strong>
<span>{{ form.username }} · {{ form.role }}</span>
</div>
</div>
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.col_name" name="name">
<CfInput v-model="form.name" />
</CfFormField>
<CfFormField :label="t.col_email" name="email">
<CfInput v-model="form.email" type="email" />
</CfFormField>
<CfFormField :label="t.col_phone" name="phone">
<CfInput v-model="form.phone" />
</CfFormField>
</CfForm>
</div>
</CfModal>
</template>
<style scoped>
.adm-profile { display: flex; flex-direction: column; gap: 16px; }
.adm-profile__avatar {
display: flex;
align-items: center;
gap: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line-1);
}
.adm-profile__name { display: flex; flex-direction: column; gap: 2px; }
.adm-profile__name strong { color: var(--fg-1); font-size: var(--t-14); }
.adm-profile__name span { color: var(--fg-3); font-size: var(--t-12); }
</style> <script setup>
/** 修改密码 modal:演示态,本地校验,不接后端。 */
import { computed, inject, ref, watch } from 'vue';
import { CfModal, CfForm, CfFormField, CfInput, CfPasswordStrength, toast } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from './state';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'update:open', value): void }>();
const form = ref({ current: '', next: '', confirm: '' });
watch(() => props.open, (v) => {
if (v) form.value = { current: '', next: '', confirm: '' };
});
function submit(): boolean {
if (!form.value.current || !form.value.next || !form.value.confirm) {
toast.error(state.locale.value === 'zh' ? '请填写完整' : 'Please fill in all fields');
return false;
}
if (form.value.next !== form.value.confirm) {
toast.error(t.value.password_mismatch);
return false;
}
toast.success(t.value.password_changed);
return true;
}
</script>
<template>
<CfModal
:open="props.open"
:title="t.change_password"
size="sm"
:ok-text="t.save"
:cancel-text="t.cancel"
:on-before-ok="submit"
@update:open="(v) => emit('update:open', v)"
>
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.current_password" name="current">
<CfInput v-model="form.current" type="password" />
</CfFormField>
<CfFormField :label="t.new_password" name="next">
<CfInput v-model="form.next" type="password" />
<CfPasswordStrength v-if="form.next" :value="form.next" size="sm" style="margin-top: 6px;" />
</CfFormField>
<CfFormField :label="t.confirm_password" name="confirm">
<CfInput v-model="form.confirm" type="password" />
</CfFormField>
</CfForm>
</CfModal>
</template> // admin-mini 演示的全局 UI 状态:主题 / 密度 / 菜单形态 / accent 色 / 语言 / 源码面板。
// 用 inject/provide 把 reactive 实例下发到所有 page,page 内部按需读取以渲染 i18n 文案。
import {} from 'vue';
export const DemoStateKey= Symbol('AdminMiniDemoState');
export const ACCENT_HUE= {
blue: 'oklch(64% 0.16 263)',
green: 'oklch(68% 0.16 150)',
purple: 'oklch(64% 0.18 300)',
orange: 'oklch(72% 0.16 60)',
rose: 'oklch(66% 0.18 15)',
};
export const STRINGS= {
zh: {
brand: 'admin-mini 后台',
switch_theme: '主题',
switch_density: '密度',
switch_menu: '菜单形态',
switch_accent: '主色',
switch_locale: '语言',
settings: '主题设置',
notifications: '通知',
no_notifications: '暂无新通知',
mark_all_read: '全部已读',
view_all: '查看全部',
profile: '个人中心',
change_password: '修改密码',
logout: '退出登录',
search_placeholder: '搜索菜单 / 用户 / 角色…',
current_password: '当前密码',
new_password: '新密码',
confirm_password: '确认新密码',
password_mismatch: '两次输入的新密码不一致',
password_changed: '密码已更新',
profile_saved: '资料已保存',
logout_done: '已退出(演示态,无实际登出)',
theme_dark_cool: '深蓝',
theme_dark_warm: '深棕',
theme_light: '浅色',
density_comfortable: '宽松',
density_compact: '紧凑',
menu_sidebar: '侧栏',
menu_topbar: '顶栏',
menu_collapsed: '折叠侧栏',
page_dashboard: '工作台',
page_users: '用户管理',
page_roles: '角色管理',
page_user_roles: '用户角色',
page_dict: '字典管理',
page_op_log: '操作日志',
page_login_log: '登录日志',
login_title: 'admin-mini 后台登录',
login_lede: '使用 ChuFix UI 组件搭建的演示后台',
login_username: '账号',
login_password: '密码',
login_remember: '记住我',
login_submit: '登录',
login_hint: '演示账号已自动填入',
login_demo_account: '演示账号:admin / admin',
login_invalid: '账号或密码错误(演示用 admin / admin)',
login_welcome: '欢迎回来,{user}',
page_menus: '菜单管理',
menu_title: '名称',
menu_icon: '图标',
menu_route: '路由',
menu_sort: '排序',
menu_visible: '显示',
menu_parent: '上级菜单',
menu_no_parent: '(无上级 / 一级菜单)',
menu_root_only: '只有一级菜单可以新增子项。',
menu_add_child: '+ 新增子菜单',
menu_select_to_edit: '请选中左侧菜单后在此编辑。',
menu_hint: '左侧树展示完整菜单层级,点击节点可在右侧编辑名称、图标、路由与排序。',
page_org: '组织架构',
col_dept: '所属部门',
org_total: '共 {n} 名成员',
org_select_hint: '点击左侧部门可筛选右侧成员,根节点显示全部。',
cmd_placeholder: '输入命令、页面或动作…',
cmd_empty: '没有匹配项',
cmd_navigate: '导航',
cmd_actions: '快捷动作',
cmd_open_settings: '打开主题设置抽屉',
cmd_open_profile: '打开个人中心',
cmd_change_password: '修改密码',
cmd_logout: '退出登录',
tour_brand_title: '欢迎来到 admin-mini',
tour_brand_desc: '这是一个完全由 ChuFix UI 组件组成的真实后台演示。',
tour_search_title: '快捷搜索',
tour_search_desc: '试试键盘上的 Ctrl + K 打开全局命令面板,跨页面跳转。',
tour_settings_title: '主题 / 密度 / 菜单形态',
tour_settings_desc: '点击齿轮可在右侧抽屉里切换主题、密度、菜单展示形态和主色。',
tour_user_title: '个人中心 + 修改密码',
tour_user_desc: '点击头像下拉,可以打开个人中心、修改密码或退出登录。',
tour_next: '下一步',
tour_finish: '完成',
tour_skip: '跳过',
page_sys_settings: '系统设置',
sys_general: '常规',
sys_security: '安全',
sys_backup: '备份',
sys_log_retention: '日志保留天数',
sys_log_retention_hint: '超过该天数的日志会自动归档',
sys_two_factor: '启用两步验证',
sys_two_factor_hint: '管理员账号强制开启',
sys_password_policy: '密码策略',
sys_password_policy_basic: '基本(≥ 8 位)',
sys_password_policy_strict: '严格(≥ 12 位 + 大小写 + 数字)',
sys_password_policy_paranoid: '偏执(≥ 16 位 + 大小写 + 数字 + 符号)',
sys_session_timeout: '会话超时(分钟)',
sys_next_backup: '下一次备份时间',
sys_upload_logs: '上传归档日志',
sys_save_changes: '保存设置',
sys_revert: '撤销',
sys_saved: '设置已保存',
sys_reverted: '已撤销',
grp_overview: '概览',
grp_authz: '权限',
grp_system: '系统',
search: '搜索',
create: '新增',
edit: '编辑',
delete: '删除',
save: '保存',
cancel: '取消',
confirm: '确定',
status: '状态',
status_active: '启用',
status_disabled: '停用',
status_ok: '成功',
status_fail: '失败',
status_success: '成功',
status_failed: '失败',
actions: '操作',
total_rows: '共 {n} 条',
col_id: 'ID',
col_username: '账号',
col_name: '姓名',
col_email: '邮箱',
col_phone: '手机号',
col_created_at: '创建时间',
col_role_name: '角色名',
col_role_desc: '描述',
col_role_perms: '权限',
perm_user_read: '查看用户',
perm_user_write: '编辑用户',
perm_role_read: '查看角色',
perm_role_write: '编辑角色',
perm_dict_read: '查看字典',
perm_dict_write: '编辑字典',
perm_log_read: '查看日志',
perm_log_export: '导出日志',
col_user_roles: '已分配角色',
col_assign: '分配角色',
col_dict_label: '名称',
col_dict_value: '值',
col_dict_remark: '备注',
col_log_user: '操作人',
col_log_action: '动作',
col_log_resource: '资源',
col_log_ip: 'IP',
col_log_at: '时间',
col_log_ua: '设备',
column_settings: '列设置',
reset_columns: '重置列',
phone_updated: '手机号已更新',
kpi_users: '用户总数',
kpi_roles: '角色总数',
kpi_login_today: '今日登录',
kpi_op_today: '今日操作',
recent_op: '最近操作',
recent_login: '最近登录',
},
en: {
brand: 'admin-mini console',
switch_theme: 'Theme',
switch_density: 'Density',
switch_menu: 'Menu',
switch_accent: 'Accent',
switch_locale: 'Locale',
settings: 'Appearance',
notifications: 'Notifications',
no_notifications: 'You are all caught up',
mark_all_read: 'Mark all read',
view_all: 'View all',
profile: 'Profile',
change_password: 'Change password',
logout: 'Sign out',
search_placeholder: 'Search menus / users / roles…',
current_password: 'Current password',
new_password: 'New password',
confirm_password: 'Confirm new password',
password_mismatch: 'New passwords do not match',
password_changed: 'Password updated',
profile_saved: 'Profile saved',
logout_done: 'Signed out (demo only — no real session)',
theme_dark_cool: 'Dark cool',
theme_dark_warm: 'Dark warm',
theme_light: 'Light',
density_comfortable: 'Comfortable',
density_compact: 'Compact',
menu_sidebar: 'Sidebar',
menu_topbar: 'Topbar',
menu_collapsed: 'Collapsed',
page_dashboard: 'Dashboard',
page_users: 'Users',
page_roles: 'Roles',
page_user_roles: 'User roles',
page_dict: 'Dictionary',
page_op_log: 'Operation log',
page_login_log: 'Login log',
login_title: 'Sign in to admin-mini',
login_lede: 'Demo console powered by ChuFix UI components',
login_username: 'Username',
login_password: 'Password',
login_remember: 'Remember me',
login_submit: 'Sign in',
login_hint: 'Demo credentials are pre-filled',
login_demo_account: 'Demo: admin / admin',
login_invalid: 'Wrong username or password (use admin / admin)',
login_welcome: 'Welcome back, {user}',
page_menus: 'Menu management',
menu_title: 'Title',
menu_icon: 'Icon',
menu_route: 'Route',
menu_sort: 'Sort',
menu_visible: 'Visible',
menu_parent: 'Parent',
menu_no_parent: '(no parent / top-level)',
menu_root_only: 'Only top-level entries accept children.',
menu_add_child: '+ New child',
menu_select_to_edit: 'Select a node on the left to edit it.',
menu_hint: 'The tree on the left shows the menu hierarchy. Click any node to edit its title, icon, route and sort weight on the right.',
page_org: 'Organization',
col_dept: 'Department',
org_total: '{n} members',
org_select_hint: 'Pick a department on the left to filter members on the right. The root shows all.',
cmd_placeholder: 'Type a command, page or action…',
cmd_empty: 'No matches',
cmd_navigate: 'Navigate',
cmd_actions: 'Quick actions',
cmd_open_settings: 'Open appearance drawer',
cmd_open_profile: 'Open profile',
cmd_change_password: 'Change password',
cmd_logout: 'Sign out',
tour_brand_title: 'Welcome to admin-mini',
tour_brand_desc: 'A live admin demo built entirely with ChuFix UI components.',
tour_search_title: 'Quick search',
tour_search_desc: 'Press Ctrl + K anywhere to open the global command palette.',
tour_settings_title: 'Theme / density / menu',
tour_settings_desc: 'The gear opens a right drawer for theme, density, menu form and accent.',
tour_user_title: 'Profile + change password',
tour_user_desc: 'The avatar drops down to Profile, Change password and Sign out.',
tour_next: 'Next',
tour_finish: 'Finish',
tour_skip: 'Skip',
page_sys_settings: 'Settings',
sys_general: 'General',
sys_security: 'Security',
sys_backup: 'Backup',
sys_log_retention: 'Log retention (days)',
sys_log_retention_hint: 'Older logs are archived automatically',
sys_two_factor: 'Enable 2FA',
sys_two_factor_hint: 'Required for admin accounts',
sys_password_policy: 'Password policy',
sys_password_policy_basic: 'Basic (≥ 8 chars)',
sys_password_policy_strict: 'Strict (≥ 12 + upper/lower + digits)',
sys_password_policy_paranoid: 'Paranoid (≥ 16 + upper/lower + digits + symbols)',
sys_session_timeout: 'Session timeout (min)',
sys_next_backup: 'Next backup at',
sys_upload_logs: 'Upload archived logs',
sys_save_changes: 'Save settings',
sys_revert: 'Revert',
sys_saved: 'Settings saved',
sys_reverted: 'Reverted',
grp_overview: 'Overview',
grp_authz: 'Authorization',
grp_system: 'System',
search: 'Search',
create: 'Create',
edit: 'Edit',
delete: 'Delete',
save: 'Save',
cancel: 'Cancel',
confirm: 'Confirm',
status: 'Status',
status_active: 'Active',
status_disabled: 'Disabled',
status_ok: 'OK',
status_fail: 'Failed',
status_success: 'Success',
status_failed: 'Failed',
actions: 'Actions',
total_rows: '{n} rows',
col_id: 'ID',
col_username: 'Username',
col_name: 'Name',
col_email: 'Email',
col_phone: 'Phone',
col_created_at: 'Created at',
col_role_name: 'Role',
col_role_desc: 'Description',
col_role_perms: 'Permissions',
perm_user_read: 'Read users',
perm_user_write: 'Edit users',
perm_role_read: 'Read roles',
perm_role_write: 'Edit roles',
perm_dict_read: 'Read dictionary',
perm_dict_write: 'Edit dictionary',
perm_log_read: 'Read logs',
perm_log_export: 'Export logs',
col_user_roles: 'Assigned roles',
col_assign: 'Assign',
col_dict_label: 'Label',
col_dict_value: 'Value',
col_dict_remark: 'Remark',
col_log_user: 'User',
col_log_action: 'Action',
col_log_resource: 'Resource',
col_log_ip: 'IP',
col_log_at: 'Time',
col_log_ua: 'Device',
column_settings: 'Columns',
reset_columns: 'Reset columns',
phone_updated: 'Phone updated',
kpi_users: 'Total users',
kpi_roles: 'Total roles',
kpi_login_today: "Today's logins",
kpi_op_today: "Today's operations",
recent_op: 'Recent operations',
recent_login: 'Recent logins',
},
}; // admin-mini 演示用的内存 mock 数据,不接任何后端。
// 各 page 直接 import 这里的常量;增删改只动各自页面持有的 ref。
export const initialMenus= [
{ id: 'm-dashboard', parentId, title: '工作台', icon: 'layout-dashboard', route: '/dashboard', sort: 1, visible: true },
{ id: 'm-authz', parentId, title: '权限', icon: 'shield', route: '', sort: 2, visible: true },
{ id: 'm-users', parentId: 'm-authz', title: '用户管理', icon: 'users', route: '/users', sort: 1, visible: true },
{ id: 'm-roles', parentId: 'm-authz', title: '角色管理', icon: 'shield-check', route: '/roles', sort: 2, visible: true },
{ id: 'm-user-roles',parentId: 'm-authz', title: '用户角色', icon: 'user-cog', route: '/user-roles', sort: 3, visible: true },
{ id: 'm-org', parentId: 'm-authz', title: '组织架构', icon: 'building-2', route: '/org', sort: 4, visible: true },
{ id: 'm-system', parentId, title: '系统', icon: 'settings', route: '', sort: 3, visible: true },
{ id: 'm-dict', parentId: 'm-system', title: '字典管理', icon: 'book-text', route: '/dict', sort: 1, visible: true },
{ id: 'm-op-log', parentId: 'm-system', title: '操作日志', icon: 'file-text', route: '/op-log', sort: 2, visible: true },
{ id: 'm-login-log', parentId: 'm-system', title: '登录日志', icon: 'log-in', route: '/login-log', sort: 3, visible: true },
{ id: 'm-menus', parentId: 'm-system', title: '菜单管理', icon: 'list-tree', route: '/menus', sort: 4, visible: true },
{ id: 'm-settings', parentId: 'm-system', title: '系统设置', icon: 'sliders', route: '/settings', sort: 5, visible: true },
];
export const initialUsers= [
{ id: 1, username: 'admin', name: '系统管理员', email: '[email protected]', phone: '13800000001', status: 'active', createdAt: '2025-09-01 10:21', deptKey: 'd-root' },
{ id: 2, username: 'ada', name: 'Ada Lovelace', email: '[email protected]', phone: '13800000002', status: 'active', createdAt: '2025-10-12 14:03', deptKey: 'd-rnd-frontend' },
{ id: 3, username: 'linus', name: 'Linus Torvalds', email: '[email protected]', phone: '13800000003', status: 'active', createdAt: '2025-11-03 09:47', deptKey: 'd-rnd-backend' },
{ id: 4, username: 'grace', name: 'Grace Hopper', email: '[email protected]', phone: '13800000004', status: 'disabled', createdAt: '2025-11-19 16:55', deptKey: 'd-ops' },
{ id: 5, username: 'alan', name: 'Alan Turing', email: '[email protected]', phone: '13800000005', status: 'active', createdAt: '2026-01-08 11:32', deptKey: 'd-rnd-backend' },
{ id: 6, username: 'donald',name: 'Donald Knuth', email: '[email protected]',phone: '13800000006', status: 'active', createdAt: '2026-02-14 13:18', deptKey: 'd-rnd-frontend' },
];
export const initialDepartments= [
{
key: 'd-root',
label: 'ChuFix Inc.',
children: [
{
key: 'd-rnd',
label: '研发中心',
children: [
{ key: 'd-rnd-frontend', label: '前端组' },
{ key: 'd-rnd-backend', label: '后端组' },
{ key: 'd-rnd-qa', label: '测试组' },
],
},
{
key: 'd-ops',
label: '运维与基础设施',
children: [
{ key: 'd-ops-sre', label: 'SRE' },
{ key: 'd-ops-dba', label: 'DBA' },
],
},
{ key: 'd-design', label: '设计中心' },
{ key: 'd-support', label: '客户支持' },
],
},
];
/** 收集某节点及其所有后代部门 key。 */
export function collectDeptKeys(dept): string[] {
const out= [dept.key];
const visit = (d) => {
if (!d.children) return;
for (const c of d.children) {
out.push(c.key);
visit(c);
}
};
visit(dept);
return out;
}
export function findDepartment(key, list: Department[] = initialDepartments): Department | null {
for (const d of list) {
if (d.key === key) return d;
if (d.children) {
const found = findDepartment(key, d.children);
if (found) return found;
}
}
return null;
}
export const initialRoles= [
{ id: 'r-admin', name: '超级管理员', description: '拥有全部权限', permissions: ['user:*', 'role:*', 'dict:*', 'log:*'] },
{ id: 'r-manager', name: '业务管理员', description: '业务模块全部读写', permissions: ['user:read', 'user:write', 'role:read'] },
{ id: 'r-viewer', name: '只读账号', description: '只能查看,不能修改', permissions: ['user:read', 'role:read', 'log:read'] },
{ id: 'r-auditor', name: '审计员', description: '查看日志,不能修改业务', permissions: ['log:read', 'log:export'] },
];
export const initialUserRoles= [
{ userId: 1, roleIds: ['r-admin'] },
{ userId: 2, roleIds: ['r-manager'] },
{ userId: 3, roleIds: ['r-manager', 'r-auditor'] },
{ userId: 4, roleIds: ['r-viewer'] },
{ userId: 5, roleIds: ['r-viewer'] },
{ userId: 6, roleIds: ['r-auditor'] },
];
export const initialDict= [
{ id: 'd-status', parentId, label: '用户状态', value: 'user_status', remark: '系统内置' },
{ id: 'd-status-1', parentId: 'd-status', label: '启用', value: 'active', remark: '' },
{ id: 'd-status-2', parentId: 'd-status', label: '停用', value: 'disabled', remark: '' },
{ id: 'd-gender', parentId, label: '性别', value: 'gender', remark: '' },
{ id: 'd-gender-1', parentId: 'd-gender', label: '男', value: 'M', remark: '' },
{ id: 'd-gender-2', parentId: 'd-gender', label: '女', value: 'F', remark: '' },
{ id: 'd-gender-3', parentId: 'd-gender', label: '保密', value: 'X', remark: '' },
{ id: 'd-level', parentId, label: '日志级别', value: 'log_level', remark: '' },
{ id: 'd-level-1', parentId: 'd-level', label: 'INFO', value: 'info', remark: '' },
{ id: 'd-level-2', parentId: 'd-level', label: 'WARN', value: 'warn', remark: '' },
{ id: 'd-level-3', parentId: 'd-level', label: 'ERROR', value: 'error', remark: '' },
];
export const initialOpLogs= [
{ id: 1, user: 'admin', action: 'create', resource: '/api/users', ip: '10.1.0.21', at: '2026-05-12 08:21:03', status: 'ok' },
{ id: 2, user: 'ada', action: 'update', resource: '/api/roles/r-manager', ip: '10.1.0.22', at: '2026-05-12 08:19:51', status: 'ok' },
{ id: 3, user: 'linus', action: 'delete', resource: '/api/users/9', ip: '10.1.0.23', at: '2026-05-12 08:17:22', status: 'fail' },
{ id: 4, user: 'ada', action: 'login', resource: '/auth/login', ip: '10.1.0.22', at: '2026-05-12 08:14:08', status: 'ok' },
{ id: 5, user: 'grace', action: 'export', resource: '/api/logs/op', ip: '10.1.0.42', at: '2026-05-12 08:10:42', status: 'ok' },
{ id: 6, user: 'admin', action: 'update', resource: '/api/dict/d-status', ip: '10.1.0.21', at: '2026-05-12 08:02:13', status: 'ok' },
];
export const initialLoginLogs= [
{ id: 1, user: 'admin', ip: '10.1.0.21', ua: 'Chrome 124 / macOS', at: '2026-05-12 08:14:08', status: 'success' },
{ id: 2, user: 'ada', ip: '10.1.0.22', ua: 'Edge 123 / Windows', at: '2026-05-12 08:11:55', status: 'success' },
{ id: 3, user: 'linus', ip: '10.1.0.23', ua: 'Firefox 124 / Linux', at: '2026-05-12 08:09:31', status: 'success' },
{ id: 4, user: 'grace', ip: '203.0.113.6', ua: 'Safari 17 / iOS', at: '2026-05-12 08:07:18', status: 'failed' },
{ id: 5, user: 'grace', ip: '203.0.113.6', ua: 'Safari 17 / iOS', at: '2026-05-12 08:06:42', status: 'failed' },
{ id: 6, user: 'alan', ip: '10.1.0.24', ua: 'Chrome 124 / Linux', at: '2026-05-12 07:58:11', status: 'success' },
]; <script setup>
/**
* 工作台(Dashboard)—— 概览大屏:
* - 4 张 CfMetricCard 含趋势 sparkline + delta
* - CfLineChart 七日登录 / 操作趋势
* - CfDonutChart 操作类型占比
* - 最近操作 + 最近登录 两张 CfTable
*/
import { computed, inject } from 'vue';
import {
CfMetricCard,
CfLineChart,
CfDonutChart,
CfTable,
CfTag,
} from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import {
initialUsers,
initialRoles,
initialOpLogs,
initialLoginLogs,
} from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const kpis = computed(() => [
{
label: t.value.kpi_users,
value: initialUsers.length,
delta: 33,
trend: [3, 4, 4, 5, 5, 6, 6],
},
{
label: t.value.kpi_roles,
value: initialRoles.length,
delta: 25,
trend: [3, 3, 3, 4, 4, 4, 4],
},
{
label: t.value.kpi_login_today,
value: 38,
delta: 12,
trend: [22, 28, 24, 30, 35, 33, 38],
},
{
label: t.value.kpi_op_today,
value: 142,
delta: -4,
trend: [160, 152, 148, 138, 142, 150, 142],
},
]);
const trendDays = computed(() =>
state.locale.value === 'zh'
? ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
);
const trendSeries = computed(() => [
{ name: state.locale.value === 'zh' ? '登录数' : 'Logins', data: [22, 28, 24, 30, 35, 33, 38] },
{ name: state.locale.value === 'zh' ? '操作数' : 'Operations', data: [120, 138, 150, 142, 158, 160, 142] },
]);
const donutSegments = computed(() => {
const counts= {};
for (const r of initialOpLogs) counts[r.action] = (counts[r.action] ?? 0) + 1;
return Object.entries(counts).map(([name, value], i) => ({
name,
value,
colorIndex: i,
}));
});
const donutTotal = computed(() => initialOpLogs.length);
const opCols = computed<TableColumn<OperationLog>[]>(() => [
{ key: 'user', title: t.value.col_log_user, dataIndex: 'user', width: 100 },
{ key: 'action', title: t.value.col_log_action, dataIndex: 'action', width: 90 },
{ key: 'resource', title: t.value.col_log_resource, dataIndex: 'resource', ellipsis: true },
{ key: 'at', title: t.value.col_log_at, dataIndex: 'at', width: 180 },
]);
const loginCols = computed<TableColumn<LoginLog>[]>(() => [
{ key: 'user', title: t.value.col_log_user, dataIndex: 'user', width: 100 },
{ key: 'ip', title: t.value.col_log_ip, dataIndex: 'ip', width: 130 },
{ key: 'ua', title: t.value.col_log_ua, dataIndex: 'ua', ellipsis: true },
{ key: 'at', title: t.value.col_log_at, dataIndex: 'at', width: 180 },
]);
const opRows = computed<OperationLog[]>(() => initialOpLogs.slice(0, 5));
const loginRows = computed<LoginLog[]>(() => initialLoginLogs.slice(0, 5));
</script>
<template>
<div class="adm-dashboard">
<div class="adm-dashboard__kpis">
<CfMetricCard
v-for="k in kpis"
:key="k.label"
:label="k.label"
:value="k.value"
:delta="k.delta"
:trend="k.trend"
/>
</div>
<div class="adm-dashboard__charts">
<section class="adm-card">
<header class="adm-card__head">
<h3>{{ state.locale.value === 'zh' ? '近 7 天趋势' : '7-day trend' }}</h3>
<CfTag size="sm" tone="info">trend</CfTag>
</header>
<div class="adm-card__body">
<CfLineChart
:series="trendSeries"
:labels="trendDays"
:height="200"
:show-grid="true"
:show-labels="true"
:show-legend="true"
:show-tooltip="true"
smooth
/>
</div>
</section>
<section class="adm-card">
<header class="adm-card__head">
<h3>{{ state.locale.value === 'zh' ? '操作类型占比' : 'Operation breakdown' }}</h3>
<CfTag size="sm" tone="success">share</CfTag>
</header>
<div class="adm-card__body adm-card__body--donut">
<CfDonutChart
:segments="donutSegments"
:size="180"
:thickness="22"
:show-legend="true"
:center-label="state.locale.value === 'zh' ? '总数' : 'Total'"
:center-value="donutTotal"
/>
</div>
</section>
</div>
<div class="adm-dashboard__pair">
<section class="adm-card">
<header class="adm-card__head">
<h3>{{ t.recent_op }}</h3>
<CfTag size="sm" tone="info">live</CfTag>
</header>
<CfTable :columns="opCols" :rows="opRows" :row-key="(r) => String(r.id)" size="sm" />
</section>
<section class="adm-card">
<header class="adm-card__head">
<h3>{{ t.recent_login }}</h3>
<CfTag size="sm" tone="success">stable</CfTag>
</header>
<CfTable :columns="loginCols" :rows="loginRows" :row-key="(r) => String(r.id)" size="sm" />
</section>
</div>
</div>
</template>
<style scoped>
.adm-dashboard { display: flex; flex-direction: column; gap: 16px; }
.adm-dashboard__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.adm-dashboard__charts {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
}
.adm-dashboard__pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 1100px) {
.adm-dashboard__charts,
.adm-dashboard__pair { grid-template-columns: 1fr; }
}
.adm-card {
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-6);
overflow: hidden;
}
.adm-card__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--line-1);
}
.adm-card__head h3 {
margin: 0;
font-size: var(--t-13);
font-weight: var(--w-medium);
color: var(--fg-1);
}
.adm-card__body { padding: 12px 14px; }
.adm-card__body--donut {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
</style> <script setup>
/**
* 用户管理页 —— 系统化展示 CfTable 的能力:
* - 多选 + 批量删除 + 全选/反选
* - 列排序:id / name / createdAt
* - 列过滤:status(select)
* - 列拖拽换序(reorderable)+ 列宽拖拽(resizable)+ 列隐藏
* - 内联编辑:双击「手机号」单元格直接改
* - 客户端分页 + 翻页器 + 每页数量切换
* - 全局搜索 + 数据 export CSV
* - sticky header + 固定右侧 actions 列
* - 新增 / 编辑 modal(CfForm + CfInput + CfSelect)+ CfConfirmDialog 二次确认
*/
import { computed, h, inject, ref } from 'vue';
import {
CfTable,
CfTag,
CfButton,
CfModal,
CfForm,
CfFormField,
CfInput,
CfSelect,
CfSearchInput,
CfCheckbox,
CfPopover,
CfConfirmDialog,
toast,
} from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import { initialUsers } from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const rows = ref<AdminUser[]>(initialUsers.map((u) => ({ ...u })));
const search = ref('');
const selected = ref<string[]>([]);
const dialogOpen = ref(false);
const editing = ref<AdminUser | null>(null);
const form = ref<Pick<AdminUser, 'username' | 'name' | 'email' | 'phone' | 'status'>>({
username: '', name: '', email: '', phone: '', status: 'active',
});
const confirmOpen = ref(false);
const pendingDelete = ref<AdminUser | null>(null);
const batchConfirmOpen = ref(false);
const columnsState = ref<TableColumnsState>({ hidden: [] });
const allColumnKeys = ['id', 'username', 'name', 'email', 'phone', 'status', 'createdAt', 'actions'];
const columnLabelMap = computed<Record<string, string>>(() => ({
id: t.value.col_id,
username: t.value.col_username,
name: t.value.col_name,
email: t.value.col_email,
phone: t.value.col_phone,
status: t.value.status,
createdAt: t.value.col_created_at,
actions: t.value.actions,
}));
function isHidden(key): boolean {
return (columnsState.value.hidden ?? []).includes(key);
}
function toggleColumn(key) {
const set = new Set(columnsState.value.hidden ?? []);
if (set.has(key)) set.delete(key);
else set.add(key);
columnsState.value = { ...columnsState.value, hidden: [...set] };
}
function resetColumns() {
columnsState.value = { hidden: [], order, widths: {} };
}
function openCreate() {
editing.value = null;
form.value = { username: '', name: '', email: '', phone: '', status: 'active' };
dialogOpen.value = true;
}
function openEdit(row) {
editing.value = row;
form.value = {
username: row.username, name: row.name, email: row.email, phone: row.phone, status: row.status,
};
dialogOpen.value = true;
}
function askDelete(row) {
pendingDelete.value = row;
confirmOpen.value = true;
}
function confirmDelete() {
const row = pendingDelete.value;
if (!row) return;
rows.value = rows.value.filter((r) => r.id !== row.id);
selected.value = selected.value.filter((id) => id !== String(row.id));
pendingDelete.value = null;
toast.success(state.locale.value === 'zh' ? '已删除 1 个用户' : 'User deleted');
}
function askBatchDelete() {
if (!selected.value.length) return;
batchConfirmOpen.value = true;
}
function confirmBatchDelete() {
const removed = selected.value.length;
const set = new Set(selected.value);
rows.value = rows.value.filter((r) => !set.has(String(r.id)));
selected.value = [];
toast.success(
state.locale.value === 'zh'
? `已删除 ${removed} 个用户`
: `Deleted ${removed} users`,
);
}
function save() {
if (!form.value.username.trim() || !form.value.name.trim()) {
toast.error(state.locale.value === 'zh' ? '账号和姓名必填' : 'Username and name required');
return false;
}
if (editing.value) {
const id = editing.value.id;
rows.value = rows.value.map((r) => (r.id === id ? { ...r, ...form.value } : r));
toast.success(state.locale.value === 'zh' ? '已更新' : 'Updated');
} else {
const nextId = (rows.value.reduce((m, r) => Math.max(m, r.id), 0) || 0) + 1;
rows.value = [
...rows.value,
{ id: nextId, ...form.value, createdAt: new Date().toISOString().slice(0, 16).replace('T', ' ') },
];
toast.success(state.locale.value === 'zh' ? '已新增' : 'Created');
}
return true;
}
function onCellEdit(payload: { row: AdminUser; column: TableColumn<AdminUser>; oldValue: unknown; newValue: unknown }) {
if (payload.column.key !== 'phone') return;
const v = String(payload.newValue).trim();
rows.value = rows.value.map((r) => (r.id === payload.row.id ? { ...r, phone: v } : r));
toast.success(t.value.phone_updated);
}
function exportCsv() {
const lines = [
['id', 'username', 'name', 'email', 'phone', 'status', 'createdAt'].join(','),
...rows.value.map((r) =>
[r.id, r.username, r.name, r.email, r.phone, r.status, r.createdAt]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(','),
),
];
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'users.csv';
a.click();
URL.revokeObjectURL(url);
toast.info(state.locale.value === 'zh' ? `已导出 ${rows.value.length} 条记录` : `Exported ${rows.value.length} rows`);
}
const cols = computed<TableColumn<AdminUser>[]>(() => [
{ key: 'id', title: t.value.col_id, dataIndex: 'id', width: 60, sortable, hideable: true },
{ key: 'username', title: t.value.col_username, dataIndex: 'username', width: 120, sortable, hideable: true },
{ key: 'name', title: t.value.col_name, dataIndex: 'name', width: 150, sortable, hideable: true },
{ key: 'email', title: t.value.col_email, dataIndex: 'email', width: 220, ellipsis, hideable: true },
{
key: 'phone', title: t.value.col_phone, dataIndex: 'phone', width: 150,
editable: true, editType: 'text',
editValidate: (v) => /^\d{6,15}$/.test(String(v).trim()),
hideable: true,
},
{
key: 'status', title: t.value.status, dataIndex: 'status', width: 120,
filterable: true,
filterType: 'select',
filterOptions: [
{ label: t.value.status_active, value: 'active' },
{ label: t.value.status_disabled, value: 'disabled' },
],
hideable: true,
render: (v) =>
h(CfTag,
{ size: 'sm', tone: v === 'active' ? 'success' : 'danger', variant: 'soft' },
() => (v === 'active' ? t.value.status_active : t.value.status_disabled)),
},
{ key: 'createdAt', title: t.value.col_created_at, dataIndex: 'createdAt', width: 180, sortable, hideable: true },
{
key: 'actions', title: t.value.actions, dataIndex: 'id', width: 150, align: 'right',
fixed: 'right',
reorderable: false,
resizable: false,
render: (_v, row) =>
h('div', { style: 'display: inline-flex; gap: 4px; justify-content: flex-end;' }, [
h(CfButton, { size: 'sm', variant: 'tertiary', onClick: () => openEdit(row) }, () => t.value.edit),
h(CfButton, { size: 'sm', variant: 'danger', onClick: () => askDelete(row) }, () => t.value.delete),
]),
},
]);
</script>
<template>
<div class="adm-page">
<header class="adm-page__head">
<div class="adm-page__head-left">
<CfSearchInput v-model="search" :placeholder="t.search" size="sm" style="width: 240px;" />
<span class="adm-page__count">
{{ t.total_rows.replace('{n}', String(rows.length)) }}
<template v-if="selected.length">
· {{ state.locale.value === 'zh' ? `已选 ${selected.length} 项` : `${selected.length} selected` }}
</template>
</span>
</div>
<div class="adm-page__head-right">
<CfPopover placement="bottom" :width="220">
<CfButton variant="tertiary" size="sm">
{{ t.column_settings }}
</CfButton>
<template #content>
<div class="adm-columns">
<header class="adm-columns__head">
<span>{{ t.column_settings }}</span>
<button type="button" class="adm-columns__reset" @click="resetColumns">
{{ t.reset_columns }}
</button>
</header>
<ul class="adm-columns__list">
<li v-for="k in allColumnKeys" :key="k">
<label class="adm-columns__item">
<CfCheckbox :model-value="!isHidden(k)" @update:modelValue="toggleColumn(k)" />
<span>{{ columnLabelMap[k] }}</span>
</label>
</li>
</ul>
</div>
</template>
</CfPopover>
<CfButton v-if="selected.length" variant="danger" size="sm" @click="askBatchDelete">
{{ state.locale.value === 'zh' ? `批量删除 (${selected.length})` : `Delete (${selected.length})` }}
</CfButton>
<CfButton variant="tertiary" size="sm" @click="exportCsv">
{{ state.locale.value === 'zh' ? '导出 CSV' : 'Export CSV' }}
</CfButton>
<CfButton variant="primary" size="sm" @click="openCreate">+ {{ t.create }}</CfButton>
</div>
</header>
<CfTable
v-model="selected"
:columns="cols"
:rows="rows"
:row-key="(r) => String(r.id)"
selectable="multiple"
size="sm"
:global-search="search"
:sticky-header="true"
:hoverable="true"
:resizable="true"
:reorderable="true"
persist-key="admin-mini:users"
:columns-state="columnsState"
@update:columns-state="(v) => (columnsState = v)"
@cell-edit="onCellEdit"
:pagination="{ page: 1, pageSize: 5, pageSizeOptions: [5, 10, 20], showSizeChanger, showJumper, showTotal: true }"
/>
<p class="adm-page__tip">
{{ state.locale.value === 'zh'
? '提示:拖拽表头可换列顺序,拖拽列分割线可调列宽,双击「手机号」单元格可直接编辑(需 6–15 位数字)。'
: 'Tip: drag headers to reorder, drag the column splitter to resize, double-click the phone cell to inline-edit (6–15 digits).' }}
</p>
<!-- 新增 / 编辑 modal -->
<CfModal
v-model:open="dialogOpen"
:title="editing ? t.edit : t.create"
:ok-text="t.save"
:cancel-text="t.cancel"
:on-before-ok="save"
size="md"
>
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.col_username" name="username">
<CfInput v-model="form.username" :placeholder="t.col_username" />
</CfFormField>
<CfFormField :label="t.col_name" name="name">
<CfInput v-model="form.name" :placeholder="t.col_name" />
</CfFormField>
<CfFormField :label="t.col_email" name="email">
<CfInput v-model="form.email" type="email" :placeholder="t.col_email" />
</CfFormField>
<CfFormField :label="t.col_phone" name="phone">
<CfInput v-model="form.phone" :placeholder="t.col_phone" />
</CfFormField>
<CfFormField :label="t.status" name="status">
<CfSelect
v-model="form.status"
:options="[
{ value: 'active', label: t.status_active },
{ value: 'disabled', label: t.status_disabled },
]"
/>
</CfFormField>
</CfForm>
</CfModal>
<!-- 单行删除确认 -->
<CfConfirmDialog
v-model:open="confirmOpen"
tone="warning"
:title="state.locale.value === 'zh' ? '确认删除?' : 'Delete this user?'"
:description="pendingDelete ? `${pendingDelete.name} (${pendingDelete.username})` : ''"
:ok-text="t.delete"
:cancel-text="t.cancel"
ok-variant="danger"
@ok="confirmDelete"
/>
<!-- 批量删除确认 -->
<CfConfirmDialog
v-model:open="batchConfirmOpen"
tone="warning"
:title="state.locale.value === 'zh' ? `批量删除 ${selected.length} 个用户?` : `Delete ${selected.length} users?`"
:description="state.locale.value === 'zh' ? '该操作不可撤销。' : 'This action cannot be undone.'"
:ok-text="t.delete"
:cancel-text="t.cancel"
ok-variant="danger"
@ok="confirmBatchDelete"
/>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-page__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.adm-page__head-left,
.adm-page__head-right {
display: inline-flex;
align-items: center;
gap: 8px;
}
.adm-page__count { color: var(--fg-3); font-size: var(--t-12); }
.adm-page__tip {
margin: 0;
color: var(--fg-3);
font-size: var(--t-11);
line-height: 1.6;
}
.adm-columns {
background: var(--bg-1);
min-width: 200px;
}
.adm-columns__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--line-1);
font-size: var(--t-12);
font-weight: var(--w-medium);
color: var(--fg-1);
}
.adm-columns__reset {
background: transparent;
border: 0;
color: var(--accent-1);
font-size: var(--t-11);
cursor: pointer;
padding: 0;
}
.adm-columns__list {
margin: 0;
padding: 6px 0;
list-style: none;
max-height: 260px;
overflow: auto;
}
.adm-columns__item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
cursor: pointer;
font-size: var(--t-12);
color: var(--fg-1);
}
.adm-columns__item:hover { background: var(--bg-2); }
</style> <script setup>
/**
* 角色管理页 —— 完整 CRUD:
* - 新增 / 编辑(CfModal + CfForm + CfTreeView 组成菜单 / 按钮权限树)
* - 删除(CfConfirmDialog 二次确认)
* - 多选 + 批量删除
* - 列排序、列过滤(按权限维度)、分页
* - 权限列:tag 列表(点开 CfHoverCard 显示完整权限说明)
*/
import { computed, h, inject, ref } from 'vue';
import {
CfTable,
CfTag,
CfButton,
CfModal,
CfForm,
CfFormField,
CfInput,
CfTextarea,
CfTreeView,
CfConfirmDialog,
CfHoverCard,
toast,
} from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import { initialRoles } from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const WILDCARD_PERMS= {
'user:*': ['menu:users', 'user:read', 'user:write'],
'role:*': ['menu:roles', 'role:read', 'role:write'],
'dict:*': ['menu:dict', 'dict:read', 'dict:write'],
'log:*': ['menu:op-log', 'log:read', 'log:export'],
'*': [
'menu:dashboard',
'menu:authz',
'menu:users',
'user:read',
'user:write',
'menu:roles',
'role:read',
'role:write',
'menu:user-roles',
'user-role:read',
'user-role:write',
'menu:org',
'org:read',
'org:write',
'menu:system',
'menu:dict',
'dict:read',
'dict:write',
'menu:op-log',
'log:read',
'log:export',
'menu:login-log',
'login-log:read',
'menu:menus',
'menu:read',
'menu:write',
'menu:settings',
'settings:read',
'settings:write',
],
};
function normalizePermissions(perms): string[] {
const set = new Set<string>();
for (const perm of perms) {
const expanded = WILDCARD_PERMS[perm] ?? [perm];
for (const key of expanded) set.add(key);
}
return [...set];
}
const rows = ref<AdminRole[]>(initialRoles.map((r) => ({ ...r, permissions: normalizePermissions(r.permissions) })));
const selected = ref<string[]>([]);
const dialogOpen = ref(false);
const editing = ref<AdminRole | null>(null);
const form = ref<{ name: string; description: string; permissions: string[] }>({
name: '', description: '', permissions: [],
});
const confirmOpen = ref(false);
const pendingDelete = ref<AdminRole | null>(null);
const batchConfirmOpen = ref(false);
const permissionTree = computed<TreeNode[]>(() => {
const zh = state.locale.value === 'zh';
return [
{ key: 'menu:dashboard', label: zh ? '菜单:工作台' : 'Menu: Dashboard' },
{
key: 'menu:authz',
label: zh ? '菜单:权限' : 'Menu: Permissions',
children: [
{ key: 'menu:users', label: zh ? '用户管理' : 'Users', children: [
{ key: 'user:read', label: t.value.perm_user_read },
{ key: 'user:write', label: t.value.perm_user_write },
] },
{ key: 'menu:roles', label: zh ? '角色管理' : 'Roles', children: [
{ key: 'role:read', label: t.value.perm_role_read },
{ key: 'role:write', label: t.value.perm_role_write },
] },
{ key: 'menu:user-roles', label: zh ? '用户角色' : 'User roles', children: [
{ key: 'user-role:read', label: zh ? '查看用户角色' : 'View user roles' },
{ key: 'user-role:write', label: zh ? '分配用户角色' : 'Assign user roles' },
] },
{ key: 'menu:org', label: zh ? '组织架构' : 'Organization', children: [
{ key: 'org:read', label: zh ? '查看组织' : 'View organization' },
{ key: 'org:write', label: zh ? '维护组织' : 'Manage organization' },
] },
],
},
{
key: 'menu:system',
label: zh ? '菜单:系统' : 'Menu: System',
children: [
{ key: 'menu:dict', label: zh ? '字典管理' : 'Dictionary', children: [
{ key: 'dict:read', label: t.value.perm_dict_read },
{ key: 'dict:write', label: t.value.perm_dict_write },
] },
{ key: 'menu:op-log', label: zh ? '操作日志' : 'Operation logs', children: [
{ key: 'log:read', label: t.value.perm_log_read },
{ key: 'log:export', label: t.value.perm_log_export },
] },
{ key: 'menu:login-log', label: zh ? '登录日志' : 'Login logs', children: [
{ key: 'login-log:read', label: zh ? '查看登录日志' : 'View login logs' },
] },
{ key: 'menu:menus', label: zh ? '菜单管理' : 'Menu management', children: [
{ key: 'menu:read', label: zh ? '查看菜单' : 'View menus' },
{ key: 'menu:write', label: zh ? '维护菜单' : 'Manage menus' },
] },
{ key: 'menu:settings', label: zh ? '系统设置' : 'Settings', children: [
{ key: 'settings:read', label: zh ? '查看设置' : 'View settings' },
{ key: 'settings:write', label: zh ? '保存设置' : 'Save settings' },
] },
],
},
];
});
const expandedPermissionKeys = computed(() => [
'menu:authz',
'menu:users',
'menu:roles',
'menu:user-roles',
'menu:org',
'menu:system',
'menu:dict',
'menu:op-log',
'menu:login-log',
'menu:menus',
'menu:settings',
]);
function openCreate() {
editing.value = null;
form.value = { name: '', description: '', permissions: [] };
dialogOpen.value = true;
}
function openEdit(row) {
editing.value = row;
form.value = { name: row.name, description: row.description, permissions: [...row.permissions] };
dialogOpen.value = true;
}
function askDelete(row) {
pendingDelete.value = row;
confirmOpen.value = true;
}
function confirmDelete() {
const r = pendingDelete.value;
if (!r) return;
rows.value = rows.value.filter((x) => x.id !== r.id);
selected.value = selected.value.filter((id) => id !== r.id);
pendingDelete.value = null;
toast.success(state.locale.value === 'zh' ? '已删除 1 个角色' : 'Role deleted');
}
function askBatchDelete() {
if (!selected.value.length) return;
batchConfirmOpen.value = true;
}
function confirmBatchDelete() {
const removed = selected.value.length;
const set = new Set(selected.value);
rows.value = rows.value.filter((r) => !set.has(r.id));
selected.value = [];
toast.success(
state.locale.value === 'zh' ? `已删除 ${removed} 个角色` : `Deleted ${removed} roles`,
);
}
function save(): boolean {
if (!form.value.name.trim()) {
toast.error(state.locale.value === 'zh' ? '角色名必填' : 'Role name required');
return false;
}
if (editing.value) {
const id = editing.value.id;
rows.value = rows.value.map((r) => (r.id === id ? { ...r, ...form.value } : r));
toast.success(state.locale.value === 'zh' ? '已更新' : 'Updated');
} else {
const nextId = `r-${Date.now().toString(36)}`;
rows.value = [...rows.value, { id: nextId, ...form.value }];
toast.success(state.locale.value === 'zh' ? '已新增' : 'Created');
}
return true;
}
const cols = computed<TableColumn<AdminRole>[]>(() => [
{ key: 'name', title: t.value.col_role_name, dataIndex: 'name', width: 180, sortable: true },
{ key: 'description', title: t.value.col_role_desc, dataIndex: 'description', ellipsis: true },
{
key: 'permissions', title: t.value.col_role_perms, dataIndex: 'permissions',
render: (v, row) => {
const perms = v;
const display = perms.slice(0, 3);
const more = perms.length - display.length;
return h(CfHoverCard, { placement: 'top' }, {
default: () => h('div', { style: 'display: inline-flex; gap: 4px; flex-wrap: wrap; cursor: help;' }, [
...display.map((p) =>
h(CfTag, { size: 'sm', variant: 'outline', tone: 'primary' }, () => p),
),
...(more > 0 ? [h(CfTag, { size: 'sm', variant: 'soft' as const, tone: 'neutral' as const }, () => `+${more}`)] : []),
]),
content: () => h('div', { style: 'padding: 6px 4px; max-width: 240px;' }, [
h('div', { style: 'font-weight: var(--w-medium); margin-bottom: 6px;' }, row.name),
h('div', { style: 'display: flex; flex-direction: column; gap: 4px;' },
perms.length
? perms.map((p) => h('code', { style: 'font-size: 11px; color: var(--fg-2);' }, p))
: [h('span', { style: 'color: var(--fg-3); font-size: 11px;' }, '—')],
),
]),
});
},
},
{
key: 'actions', title: t.value.actions, dataIndex: 'id', width: 140, align: 'right',
fixed: 'right',
render: (_v, row) =>
h('div', { style: 'display: inline-flex; gap: 4px; justify-content: flex-end;' }, [
h(CfButton, { size: 'sm', variant: 'tertiary', onClick: () => openEdit(row) }, () => t.value.edit),
h(CfButton, { size: 'sm', variant: 'danger', onClick: () => askDelete(row) }, () => t.value.delete),
]),
},
]);
</script>
<template>
<div class="adm-page">
<header class="adm-page__head">
<div class="adm-page__head-left">
<span class="adm-page__count">
{{ t.total_rows.replace('{n}', String(rows.length)) }}
<template v-if="selected.length">
· {{ state.locale.value === 'zh' ? `已选 ${selected.length} 项` : `${selected.length} selected` }}
</template>
</span>
</div>
<div class="adm-page__head-right">
<CfButton v-if="selected.length" variant="danger" size="sm" @click="askBatchDelete">
{{ state.locale.value === 'zh' ? `批量删除 (${selected.length})` : `Delete (${selected.length})` }}
</CfButton>
<CfButton variant="primary" size="sm" @click="openCreate">+ {{ t.create }}</CfButton>
</div>
</header>
<CfTable
v-model="selected"
:columns="cols"
:rows="rows"
:row-key="(r) => r.id"
selectable="multiple"
size="sm"
:sticky-header="true"
:hoverable="true"
:pagination="{ page: 1, pageSize: 5, pageSizeOptions: [5, 10], showSizeChanger, showTotal: true }"
/>
<!-- 新增 / 编辑 modal -->
<CfModal
v-model:open="dialogOpen"
:title="editing ? `${t.edit}:${editing.name}` : t.create"
:ok-text="t.save"
:cancel-text="t.cancel"
:on-before-ok="save"
size="md"
>
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.col_role_name" name="name">
<CfInput v-model="form.name" />
</CfFormField>
<CfFormField :label="t.col_role_desc" name="description">
<CfTextarea v-model="form.description" :rows="2" />
</CfFormField>
<CfFormField :label="t.col_role_perms" name="permissions">
<div class="adm-roles__perm-tree">
<CfTreeView
v-model="form.permissions"
:nodes="permissionTree"
:default-expanded-keys="expandedPermissionKeys"
checkable
:cascade="true"
size="sm"
:show-line="true"
/>
</div>
</CfFormField>
</CfForm>
</CfModal>
<!-- 单行删除确认 -->
<CfConfirmDialog
v-model:open="confirmOpen"
tone="warning"
:title="state.locale.value === 'zh' ? '确认删除?' : 'Delete this role?'"
:description="pendingDelete ? pendingDelete.name : ''"
:ok-text="t.delete"
:cancel-text="t.cancel"
ok-variant="danger"
@ok="confirmDelete"
/>
<CfConfirmDialog
v-model:open="batchConfirmOpen"
tone="warning"
:title="state.locale.value === 'zh' ? `批量删除 ${selected.length} 个角色?` : `Delete ${selected.length} roles?`"
:description="state.locale.value === 'zh' ? '已分配用户将丢失对应角色,该操作不可撤销。' : 'Users with these roles will lose them. This cannot be undone.'"
:ok-text="t.delete"
:cancel-text="t.cancel"
ok-variant="danger"
@ok="confirmBatchDelete"
/>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-page__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.adm-page__head-left,
.adm-page__head-right {
display: inline-flex;
align-items: center;
gap: 8px;
}
.adm-page__count { color: var(--fg-3); font-size: var(--t-12); }
.adm-roles__perm-tree {
max-height: 320px;
overflow: auto;
padding: 8px;
border: 1px solid var(--line-1);
border-radius: var(--r-4);
background: var(--bg-1);
}
</style> <script setup>
import { computed, h, inject, ref } from 'vue';
import { CfTable, CfTag, CfButton, CfModal, CfCheckbox } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import {
initialUsers,
initialRoles,
initialUserRoles,
} from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const links = ref<UserRoleLink[]>(initialUserRoles.map((l) => ({ ...l, roleIds: [...l.roleIds] })));
function rolesFor(userId): string[] {
return links.value.find((l) => l.userId === userId)?.roleIds ?? [];
}
function roleName(id): string {
return initialRoles.find((r) => r.id === id)?.name ?? id;
}
const dialogOpen = ref(false);
const editingUser = ref<AdminUser | null>(null);
const draft = ref<Set<string>>(new Set());
function openAssign(u) {
editingUser.value = u;
draft.value = new Set(rolesFor(u.id));
dialogOpen.value = true;
}
function toggleRole(roleId) {
if (draft.value.has(roleId)) draft.value.delete(roleId);
else draft.value.add(roleId);
draft.value = new Set(draft.value);
}
function saveAssign() {
if (!editingUser.value) return;
const uid = editingUser.value.id;
const next = [...draft.value];
const exists = links.value.some((l) => l.userId === uid);
links.value = exists
? links.value.map((l) => (l.userId === uid ? { ...l, roleIds: next } : l))
: [...links.value, { userId: uid, roleIds: next }];
dialogOpen.value = false;
}
const cols = computed(() => [
{ key: 'username', title: t.value.col_username, dataIndex: 'username', width: 110 },
{ key: 'name', title: t.value.col_name, dataIndex: 'name', width: 140 },
{
key: 'roles', title: t.value.col_user_roles, dataIndex: 'id',
render: (v) => {
const ids = rolesFor(v);
if (!ids.length) {
return h('span', { style: 'color: var(--fg-3);' }, '—');
}
return h('div', { style: 'display: inline-flex; gap: 4px; flex-wrap: wrap;' },
ids.map((id) =>
h(CfTag, { size: 'sm', variant: 'soft', tone: 'primary' }, () => roleName(id)),
),
);
},
},
{
key: 'assign', title: t.value.col_assign, dataIndex: 'id', width: 110, align: 'right',
render: (_v, row) =>
h(CfButton, { size: 'sm', variant: 'tertiary', onClick: () => openAssign(row) }, () => t.value.edit),
},
]);
</script>
<template>
<div class="adm-page">
<CfTable :columns="cols" :rows="initialUsers" :row-key="(r) => r.id" size="sm" />
<CfModal
v-model:open="dialogOpen"
:title="editingUser ? `${editingUser.name} · ${t.col_assign}` : t.col_assign"
:ok-text="t.save"
:cancel-text="t.cancel"
:on-before-ok="() => { saveAssign(); return true; }"
size="sm"
>
<div class="adm-assign">
<label v-for="r in initialRoles" :key="r.id" class="adm-assign__row">
<CfCheckbox :model-value="draft.has(r.id)" @update:modelValue="toggleRole(r.id)" />
<span class="adm-assign__name">{{ r.name }}</span>
<span class="adm-assign__desc">{{ r.description }}</span>
</label>
</div>
</CfModal>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-assign { display: flex; flex-direction: column; gap: 8px; }
.adm-assign__row {
display: grid;
grid-template-columns: 22px 120px 1fr;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--r-4);
cursor: pointer;
}
.adm-assign__row:hover { background: var(--bg-2); }
.adm-assign__name { font-weight: var(--w-medium); color: var(--fg-1); }
.adm-assign__desc { color: var(--fg-3); font-size: var(--t-12); }
</style> <script setup>
/**
* 字典管理页 —— 父子树形表格 + 完整 CRUD:
* - 树形 CfTable(父字典 → 子项)+ 默认展开
* - 新增父字典 / 新增子项 / 编辑 / 删除
* - 删除二次确认(CfConfirmDialog),删除父字典会连带删除其所有子项
*/
import { computed, h, inject, ref } from 'vue';
import {
CfTable,
CfTag,
CfButton,
CfModal,
CfForm,
CfFormField,
CfInput,
CfTextarea,
CfConfirmDialog,
toast,
} from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import { initialDict } from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const rows = ref<DictItem[]>(initialDict.map((d) => ({ ...d })));
const tree = computed<DictNode[]>(() => {
const byParent = new Map<string | null, DictItem[]>();
for (const item of rows.value) {
const arr = byParent.get(item.parentId) ?? [];
arr.push(item);
byParent.set(item.parentId, arr);
}
const roots = byParent.get(null) ?? [];
return roots.map((r) => ({ ...r, children: byParent.get(r.id) ?? [] }));
});
const dialogOpen = ref(false);
const editing = ref<DictItem | null>(null);
const parentOf = ref<DictItem | null>(null);
const form = ref<{ label: string; value: string; remark: string }>({
label: '', value: '', remark: '',
});
const confirmOpen = ref(false);
const pendingDelete = ref<DictItem | null>(null);
function openCreateRoot() {
editing.value = null;
parentOf.value = null;
form.value = { label: '', value: '', remark: '' };
dialogOpen.value = true;
}
function openCreateChild(parent) {
editing.value = null;
parentOf.value = parent;
form.value = { label: '', value: '', remark: '' };
dialogOpen.value = true;
}
function openEdit(row) {
editing.value = row;
parentOf.value = null;
form.value = { label: row.label, value: row.value, remark: row.remark };
dialogOpen.value = true;
}
function askDelete(row) {
pendingDelete.value = row;
confirmOpen.value = true;
}
function confirmDelete() {
const r = pendingDelete.value;
if (!r) return;
const removeIds = new Set<string>([r.id]);
// 父字典连带删除全部子项
for (const it of rows.value) {
if (it.parentId === r.id) removeIds.add(it.id);
}
rows.value = rows.value.filter((x) => !removeIds.has(x.id));
pendingDelete.value = null;
toast.success(
state.locale.value === 'zh'
? `已删除 ${removeIds.size} 个条目`
: `Deleted ${removeIds.size} entries`,
);
}
function save(): boolean {
if (!form.value.label.trim() || !form.value.value.trim()) {
toast.error(state.locale.value === 'zh' ? '名称和值必填' : 'Label and value required');
return false;
}
if (editing.value) {
const id = editing.value.id;
rows.value = rows.value.map((r) => (r.id === id ? { ...r, ...form.value } : r));
toast.success(state.locale.value === 'zh' ? '已更新' : 'Updated');
} else {
const nextId = `d-${Date.now().toString(36)}`;
rows.value = [
...rows.value,
{ id: nextId, parentId: parentOf.value?.id ?? null, ...form.value },
];
toast.success(state.locale.value === 'zh' ? '已新增' : 'Created');
}
return true;
}
const dialogTitle = computed(() => {
if (editing.value) return `${t.value.edit}:${editing.value.label}`;
if (parentOf.value) {
return state.locale.value === 'zh'
? `新增子项 → ${parentOf.value.label}`
: `New child → ${parentOf.value.label}`;
}
return state.locale.value === 'zh' ? '新增父字典' : 'New root entry';
});
const deleteDescription = computed(() => {
const r = pendingDelete.value;
if (!r) return '';
if (r.parentId == null) {
return state.locale.value === 'zh'
? `将连带删除「${r.label}」的所有子项,无法撤销。`
: `All children of "${r.label}" will also be deleted. This cannot be undone.`;
}
return r.label;
});
const cols = computed<TableColumn<DictNode>[]>(() => [
{
key: 'label', title: t.value.col_dict_label, dataIndex: 'label', width: 240,
render: (v, row) => {
const children = [h('span', String(v))];
if (row.parentId == null) {
children.push(
h(CfTag, { size: 'sm', variant: 'soft', tone: 'primary' },
() => state.locale.value === 'zh' ? '父字典' : 'root'),
);
}
return h('span', { style: 'display: inline-flex; align-items: center; gap: 6px;' }, children);
},
},
{ key: 'value', title: t.value.col_dict_value, dataIndex: 'value', width: 200 },
{ key: 'remark', title: t.value.col_dict_remark, dataIndex: 'remark', ellipsis: true },
{
key: 'actions', title: t.value.actions, dataIndex: 'id', width: 220, align: 'right',
fixed: 'right',
render: (_v, row) => {
const buttons = [];
if (row.parentId == null) {
buttons.push(
h(CfButton, { size: 'sm', variant: 'tertiary', onClick: () => openCreateChild(row) },
() => state.locale.value === 'zh' ? '+ 子项' : '+ Child'),
);
}
buttons.push(
h(CfButton, { size: 'sm', variant: 'tertiary', onClick: () => openEdit(row) }, () => t.value.edit),
h(CfButton, { size: 'sm', variant: 'danger', onClick: () => askDelete(row) }, () => t.value.delete),
);
return h('div', { style: 'display: inline-flex; gap: 4px; justify-content: flex-end;' }, buttons);
},
},
]);
const expandedKeys = computed(() => tree.value.map((n) => n.id));
</script>
<template>
<div class="adm-page">
<header class="adm-page__head">
<span class="adm-page__count">
{{ state.locale.value === 'zh'
? `共 ${rows.length} 个条目(${tree.length} 个父字典)`
: `${rows.length} entries (${tree.length} roots)` }}
</span>
<CfButton variant="primary" size="sm" @click="openCreateRoot">
+ {{ state.locale.value === 'zh' ? '新增父字典' : 'New root' }}
</CfButton>
</header>
<CfTable
:columns="cols"
:rows="tree"
:row-key="(r) => r.id"
:default-expanded-row-keys="expandedKeys"
size="sm"
:sticky-header="true"
:hoverable="true"
/>
<CfModal
v-model:open="dialogOpen"
:title="dialogTitle"
:ok-text="t.save"
:cancel-text="t.cancel"
:on-before-ok="save"
size="sm"
>
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.col_dict_label" name="label">
<CfInput v-model="form.label" />
</CfFormField>
<CfFormField :label="t.col_dict_value" name="value">
<CfInput v-model="form.value" />
</CfFormField>
<CfFormField :label="t.col_dict_remark" name="remark">
<CfTextarea v-model="form.remark" :rows="2" />
</CfFormField>
</CfForm>
</CfModal>
<CfConfirmDialog
v-model:open="confirmOpen"
tone="warning"
:title="state.locale.value === 'zh' ? '确认删除?' : 'Delete this entry?'"
:description="deleteDescription"
:ok-text="t.delete"
:cancel-text="t.cancel"
ok-variant="danger"
@ok="confirmDelete"
/>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-page__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.adm-page__count { color: var(--fg-3); font-size: var(--t-12); }
</style> <script setup>
/**
* 操作日志 —— 展示更多 CfTable 能力:
* - 全局搜索 + 按 action / status 列过滤
* - 排序:时间倒序为默认
* - 多选 + 批量删除(演示态)
* - 分页 / 翻页 / 调整每页数量
* - sticky header + 固定右侧 actions 列
* - 导出 CSV
* - row-click 展开详情(CfDescriptionList)
*/
import { computed, h, inject, ref } from 'vue';
import {
CfTable,
CfTag,
CfButton,
CfSearchInput,
CfDescriptionList,
CfConfirmDialog,
toast,
} from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import { initialOpLogs } from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const rows = ref<OperationLog[]>(initialOpLogs.map((r) => ({ ...r })));
const search = ref('');
const selected = ref<string[]>([]);
const expanded = ref<string[]>([]);
const batchConfirmOpen = ref(false);
function askBatchDelete() {
if (selected.value.length) batchConfirmOpen.value = true;
}
function confirmBatchDelete() {
const set = new Set(selected.value);
const n = selected.value.length;
rows.value = rows.value.filter((r) => !set.has(String(r.id)));
selected.value = [];
toast.success(state.locale.value === 'zh' ? `已删除 ${n} 条日志` : `Deleted ${n} log entries`);
}
function exportCsv() {
const lines = [
['id', 'user', 'action', 'resource', 'ip', 'at', 'status'].join(','),
...rows.value.map((r) =>
[r.id, r.user, r.action, r.resource, r.ip, r.at, r.status]
.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(','),
),
];
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'operation-log.csv';
a.click();
URL.revokeObjectURL(a.href);
toast.info(state.locale.value === 'zh' ? `已导出 ${rows.value.length} 条记录` : `Exported ${rows.value.length} rows`);
}
const cols = computed<TableColumn<OperationLog>[]>(() => [
{ key: 'user', title: t.value.col_log_user, dataIndex: 'user', width: 110, sortable: true },
{
key: 'action', title: t.value.col_log_action, dataIndex: 'action', width: 110,
filterable: true,
filterType: 'select',
filterOptions: [
{ label: 'create', value: 'create' },
{ label: 'update', value: 'update' },
{ label: 'delete', value: 'delete' },
{ label: 'login', value: 'login' },
{ label: 'export', value: 'export' },
],
render: (v) =>
h(CfTag, { size: 'sm', variant: 'outline', tone: v === 'delete' ? 'danger' : 'neutral' }, () => String(v)),
},
{ key: 'resource', title: t.value.col_log_resource, dataIndex: 'resource', ellipsis: true },
{ key: 'ip', title: t.value.col_log_ip, dataIndex: 'ip', width: 130 },
{
key: 'status', title: t.value.status, dataIndex: 'status', width: 100,
filterable: true,
filterType: 'select',
filterOptions: [
{ label: t.value.status_ok, value: 'ok' },
{ label: t.value.status_fail, value: 'fail' },
],
render: (v) =>
h(CfTag,
{ size: 'sm', tone: v === 'ok' ? 'success' : 'danger', variant: 'soft' },
() => (v === 'ok' ? t.value.status_ok : t.value.status_fail)),
},
{ key: 'at', title: t.value.col_log_at, dataIndex: 'at', width: 180, sortable: true },
]);
function descItems(row): DescriptionItem[] {
const actionText = state.locale.value === 'zh'
? ({ create: '新增用户', update: '更新角色权限', delete: '删除用户', login: '登录认证', export: '导出审计日志' })[row.action] ?? row.action
: ({ create: 'Create user', update: 'Update role permissions', delete: 'Delete user', login: 'Login auth', export: 'Export audit logs' })[row.action] ?? row.action;
const resultText = row.status === 'ok'
? (state.locale.value === 'zh' ? '请求已完成,审计事件已写入日志。' : 'Request completed and audit event was persisted.')
: (state.locale.value === 'zh' ? '权限校验未通过,后端拒绝执行。' : 'Permission check failed and the backend rejected the request.');
return [
{ term: t.value.col_log_user, description: row.user },
{ term: t.value.col_log_action, description: `${row.action} · ${actionText}` },
{ term: t.value.col_log_resource, description: row.resource },
{ term: t.value.col_log_ip, description: row.ip },
{ term: t.value.col_log_at, description: row.at },
{ term: state.locale.value === 'zh' ? '请求编号' : 'Request ID', description: `REQ-${String(row.id).padStart(5, '0')}` },
{ term: state.locale.value === 'zh' ? '请求方法' : 'Method', description: row.action === 'login' ? 'POST' : row.action === 'delete' ? 'DELETE' : row.action === 'create' ? 'POST' : 'PATCH' },
{ term: t.value.status, description: row.status === 'ok' ? t.value.status_ok : t.value.status_fail },
{ term: state.locale.value === 'zh' ? '处理结果' : 'Result', description: resultText },
];
}
</script>
<template>
<div class="adm-page">
<header class="adm-page__head">
<div class="adm-page__head-left">
<CfSearchInput v-model="search" :placeholder="t.search" size="sm" style="width: 240px;" />
<span class="adm-page__count">
{{ t.total_rows.replace('{n}', String(rows.length)) }}
<template v-if="selected.length">
· {{ state.locale.value === 'zh' ? `已选 ${selected.length} 项` : `${selected.length} selected` }}
</template>
</span>
</div>
<div class="adm-page__head-right">
<CfButton v-if="selected.length" variant="danger" size="sm" @click="askBatchDelete">
{{ state.locale.value === 'zh' ? `批量删除 (${selected.length})` : `Delete (${selected.length})` }}
</CfButton>
<CfButton variant="tertiary" size="sm" @click="exportCsv">
{{ state.locale.value === 'zh' ? '导出 CSV' : 'Export CSV' }}
</CfButton>
</div>
</header>
<CfTable
v-model="selected"
:columns="cols"
:rows="rows"
:row-key="(r) => String(r.id)"
selectable="multiple"
size="sm"
:global-search="search"
:default-sort="{ key: 'at', direction: 'desc' }"
:sticky-header="true"
:hoverable="true"
:expandable="true"
v-model:expanded-row-keys="expanded"
:expand-render="(row) => h('div', { style: 'padding: 8px 12px;' }, [h(CfDescriptionList, { items: descItems(row), layout: 'horizontal', size: 'sm' })])"
:pagination="{ page: 1, pageSize: 5, pageSizeOptions: [5, 10, 20], showSizeChanger, showJumper, showTotal: true }"
/>
<CfConfirmDialog
v-model:open="batchConfirmOpen"
tone="warning"
:title="state.locale.value === 'zh' ? `删除 ${selected.length} 条日志?` : `Delete ${selected.length} log entries?`"
:description="state.locale.value === 'zh' ? '审计日志一般不应删除,此处仅作演示。' : 'Audit logs are typically immutable — this is demo only.'"
:ok-text="t.delete"
:cancel-text="t.cancel"
ok-variant="danger"
@ok="confirmBatchDelete"
/>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-page__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.adm-page__head-left,
.adm-page__head-right {
display: inline-flex;
align-items: center;
gap: 8px;
}
.adm-page__count { color: var(--fg-3); font-size: var(--t-12); }
</style> <script setup>
/**
* 登录日志 —— 类似操作日志,重点展示:
* - 失败次数统计 + 高亮失败用户(rowClass)
* - 按状态过滤
* - 排序时间倒序为默认
* - 分页 + 导出 CSV
*/
import { computed, h, inject, ref } from 'vue';
import {
CfTable,
CfTag,
CfButton,
CfSearchInput,
toast,
} from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import { initialLoginLogs } from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const rows = ref<LoginLog[]>(initialLoginLogs.map((r) => ({ ...r })));
const search = ref('');
function exportCsv() {
const lines = [
['id', 'user', 'ip', 'ua', 'at', 'status'].join(','),
...rows.value.map((r) =>
[r.id, r.user, r.ip, r.ua, r.at, r.status]
.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(','),
),
];
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'login-log.csv';
a.click();
URL.revokeObjectURL(a.href);
toast.info(state.locale.value === 'zh' ? `已导出 ${rows.value.length} 条记录` : `Exported ${rows.value.length} rows`);
}
const cols = computed<TableColumn<LoginLog>[]>(() => [
{ key: 'user', title: t.value.col_log_user, dataIndex: 'user', width: 110, sortable: true },
{ key: 'ip', title: t.value.col_log_ip, dataIndex: 'ip', width: 140 },
{ key: 'ua', title: t.value.col_log_ua, dataIndex: 'ua', ellipsis: true },
{
key: 'status', title: t.value.status, dataIndex: 'status', width: 110,
filterable: true,
filterType: 'select',
filterOptions: [
{ label: t.value.status_success, value: 'success' },
{ label: t.value.status_failed, value: 'failed' },
],
render: (v) =>
h(CfTag,
{ size: 'sm', tone: v === 'success' ? 'success' : 'danger', variant: 'soft' },
() => (v === 'success' ? t.value.status_success : t.value.status_failed)),
},
{ key: 'at', title: t.value.col_log_at, dataIndex: 'at', width: 180, sortable: true },
]);
const failCount = computed(() => rows.value.filter((r) => r.status === 'failed').length);
</script>
<template>
<div class="adm-page">
<header class="adm-page__head">
<div class="adm-page__head-left">
<CfSearchInput v-model="search" :placeholder="t.search" size="sm" style="width: 240px;" />
<span class="adm-page__count">
{{ t.total_rows.replace('{n}', String(rows.length)) }}
<template v-if="failCount">
· <span style="color: oklch(70% 0.15 25);">{{ state.locale.value === 'zh' ? `失败 ${failCount} 次` : `${failCount} failed` }}</span>
</template>
</span>
</div>
<div class="adm-page__head-right">
<CfButton variant="tertiary" size="sm" @click="exportCsv">
{{ state.locale.value === 'zh' ? '导出 CSV' : 'Export CSV' }}
</CfButton>
</div>
</header>
<CfTable
:columns="cols"
:rows="rows"
:row-key="(r) => String(r.id)"
size="sm"
:global-search="search"
:default-sort="{ key: 'at', direction: 'desc' }"
:sticky-header="true"
:hoverable="true"
:pagination="{ page: 1, pageSize: 5, pageSizeOptions: [5, 10, 20], showSizeChanger, showJumper, showTotal: true }"
/>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-page__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.adm-page__head-left,
.adm-page__head-right {
display: inline-flex;
align-items: center;
gap: 8px;
}
.adm-page__count { color: var(--fg-3); font-size: var(--t-12); }
</style> <script setup>
/**
* 系统设置页 —— 展示更多表单组件:
* CfSwitch · CfRadioGroup · CfSlider · CfNumberInput · CfDatePicker · CfDropzone
*/
import { computed, inject, ref } from 'vue';
import {
CfTabs,
CfTabPanel,
CfForm,
CfFormField,
CfSwitch,
CfRadioGroup,
CfRadio,
CfSlider,
CfNumberInput,
CfDatePicker,
CfDropzone,
CfButton,
CfTag,
toast,
} from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const initialForm = () => ({
twoFactor: true,
logRetention: 90,
sessionTimeout: 30,
passwordPolicy: 'strict' as 'basic' | 'strict' | 'paranoid',
nextBackup: '2026-05-13',
files: []
});
const form = ref(initialForm());
function save() {
toast.success(t.value.sys_saved);
}
function revert() {
form.value = initialForm();
toast.info(t.value.sys_reverted);
}
const activeTab = ref<'general' | 'security' | 'backup'>('general');
const tabItems = computed(() => [
{ value: 'general', label: t.value.sys_general },
{ value: 'security', label: t.value.sys_security },
{ value: 'backup', label: t.value.sys_backup },
]);
</script>
<template>
<div class="adm-page">
<CfTabs v-model="activeTab" :items="tabItems" variant="line">
<template #default="{ active }">
<CfTabPanel v-show="active === 'general'" value="general" :label="t.sys_general">
<div class="adm-settings">
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.sys_log_retention" name="logRetention">
<div class="adm-settings__row">
<CfSlider
v-model="form.logRetention"
:min="7"
:max="365"
:step="1"
show-value
style="flex: 1;"
/>
<CfTag size="sm" tone="info">{{ form.logRetention }} {{ state.locale.value === 'zh' ? '天' : 'days' }}</CfTag>
</div>
<p class="adm-settings__hint">{{ t.sys_log_retention_hint }}</p>
</CfFormField>
<CfFormField :label="t.sys_session_timeout" name="sessionTimeout">
<CfNumberInput v-model="form.sessionTimeout" :min="5" :max="240" :step="5" style="width: 160px;" />
</CfFormField>
</CfForm>
</div>
</CfTabPanel>
<CfTabPanel v-show="active === 'security'" value="security" :label="t.sys_security">
<div class="adm-settings">
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.sys_two_factor" name="twoFactor">
<div class="adm-settings__row">
<CfSwitch v-model="form.twoFactor" />
<span class="adm-settings__hint adm-settings__hint--inline">{{ t.sys_two_factor_hint }}</span>
</div>
</CfFormField>
<CfFormField :label="t.sys_password_policy" name="passwordPolicy">
<CfRadioGroup v-model="form.passwordPolicy">
<CfRadio value="basic">{{ t.sys_password_policy_basic }}</CfRadio>
<CfRadio value="strict">{{ t.sys_password_policy_strict }}</CfRadio>
<CfRadio value="paranoid">{{ t.sys_password_policy_paranoid }}</CfRadio>
</CfRadioGroup>
</CfFormField>
</CfForm>
</div>
</CfTabPanel>
<CfTabPanel v-show="active === 'backup'" value="backup" :label="t.sys_backup">
<div class="adm-settings">
<CfForm :model="form" layout="vertical">
<CfFormField :label="t.sys_next_backup" name="nextBackup">
<CfDatePicker v-model="form.nextBackup" :placeholder="t.sys_next_backup" style="width: 220px;" />
</CfFormField>
<CfFormField :label="t.sys_upload_logs" name="files">
<CfDropzone
v-model="form.files"
multiple
:max-size="20 * 1024 * 1024"
accept=".log,.txt,.gz,application/gzip,text/plain"
/>
</CfFormField>
</CfForm>
</div>
</CfTabPanel>
</template>
</CfTabs>
<footer class="adm-page__foot">
<CfButton variant="tertiary" @click="revert">{{ t.sys_revert }}</CfButton>
<CfButton variant="primary" @click="save">{{ t.sys_save_changes }}</CfButton>
</footer>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-settings { padding: 16px 4px 4px; max-width: 720px; }
.adm-settings__row {
display: inline-flex;
align-items: center;
gap: 12px;
width: 100%;
}
.adm-settings__hint {
margin: 6px 0 0;
color: var(--fg-3);
font-size: var(--t-11);
line-height: 1.6;
}
.adm-settings__hint--inline {
margin: 0;
}
.adm-page__foot {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid var(--line-1);
}
</style> <script setup>
/**
* 组织架构页 —— 左侧 CfTreeView(部门树)+ 右侧 CfTable(该部门下的成员)。
* 演示树形单选 + 联动表格 + 默认展开 + 选中节点过滤数据集。
*/
import { computed, h, inject, ref } from 'vue';
import { CfTreeView, CfTable, CfTag, CfSplitter, CfBreadcrumb } from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import {
initialUsers,
initialDepartments,
collectDeptKeys,
findDepartment,
} from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const selectedKey = ref<string>('d-root');
const expandedKeys = ref<string[]>(['d-root', 'd-rnd', 'd-ops']);
function toTreeNodes(list): TreeNode[] {
return list.map((d) => ({
key: d.key,
label: d.label,
children: d.children ? toTreeNodes(d.children) : undefined,
}));
}
const treeNodes = computed<TreeNode[]>(() => toTreeNodes(initialDepartments));
const visibleUsers = computed<AdminUser[]>(() => {
const dept = findDepartment(selectedKey.value);
if (!dept) return initialUsers;
const allowed = new Set(collectDeptKeys(dept));
return initialUsers.filter((u) => u.deptKey && allowed.has(u.deptKey));
});
const breadcrumbItems = computed(() => {
const dept = findDepartment(selectedKey.value);
return dept ? [{ label: dept.label }] : [];
});
const cols = computed<TableColumn<AdminUser>[]>(() => [
{ key: 'username', title: t.value.col_username, dataIndex: 'username', width: 120 },
{ key: 'name', title: t.value.col_name, dataIndex: 'name', width: 140 },
{ key: 'email', title: t.value.col_email, dataIndex: 'email', ellipsis: true },
{
key: 'deptKey', title: t.value.col_dept, dataIndex: 'deptKey', width: 160,
render: (v) => {
const dept = v ? findDepartment(String(v)) : null;
return dept
? h(CfTag, { size: 'sm', variant: 'soft', tone: 'primary' }, () => dept.label)
: h('span', { style: 'color: var(--fg-3);' }, '—');
},
},
{
key: 'status', title: t.value.status, dataIndex: 'status', width: 100,
render: (v) =>
h(CfTag,
{ size: 'sm', tone: v === 'active' ? 'success' : 'danger', variant: 'soft' },
() => (v === 'active' ? t.value.status_active : t.value.status_disabled)),
},
]);
</script>
<template>
<div class="adm-page">
<CfBreadcrumb v-if="breadcrumbItems.length" :items="breadcrumbItems" />
<p class="adm-page__hint">{{ t.org_select_hint }}</p>
<CfSplitter orientation="horizontal" :default-size="30" unit="%" class="adm-org">
<template #start>
<div class="adm-org__tree">
<CfTreeView
:nodes="treeNodes"
selectable="single"
:selected-key="selectedKey"
v-model:expanded-keys="expandedKeys"
:show-line="true"
size="sm"
@update:selectedKey="(k) => (selectedKey = k ?? 'd-root')"
/>
</div>
</template>
<template #end>
<div class="adm-org__main">
<div class="adm-org__count">
{{ t.org_total.replace('{n}', String(visibleUsers.length)) }}
</div>
<CfTable
:columns="cols"
:rows="visibleUsers"
:row-key="(r) => String(r.id)"
size="sm"
:sticky-header="true"
:hoverable="true"
/>
</div>
</template>
</CfSplitter>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-page__hint { margin: 0; color: var(--fg-3); font-size: var(--t-12); }
.adm-org { min-height: 420px; }
.adm-org__tree {
height: 100%;
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-4);
padding: 8px;
overflow: auto;
}
.adm-org__main {
height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
padding-left: 12px;
}
.adm-org__count {
color: var(--fg-3);
font-size: var(--t-12);
}
</style> <script setup>
/**
* 菜单管理 —— 左侧 CfTreeView 展示菜单层级,右侧 CfForm 编辑选中节点。
* 关键 ChuFix 组件展示:
* - CfTreeView:层级结构、显示连接线、单选、节点图标
* - CfIconPicker:图标选择
* - CfForm + CfInput + CfNumberInput + CfSwitch + CfTreeSelect:节点元数据
* - CfSplitter:左右两栏可拖拽
* - CfConfirmDialog:删除二次确认
*/
import { computed, inject, ref, watch } from 'vue';
import {
CfTreeView,
CfSplitter,
CfForm,
CfFormField,
CfInput,
CfNumberInput,
CfSwitch,
CfTreeSelect,
CfIconPicker,
CfButton,
CfConfirmDialog,
CfIcon,
CfEmpty,
toast,
} from '@chufix-design/vue';
import { DemoStateKey, STRINGS } from '../state';
import { initialMenus } from '../mock';
const state = inject(DemoStateKey)!;
const t = computed(() => STRINGS[state.locale.value]);
const menus = ref<MenuEntry[]>(initialMenus.map((m) => ({ ...m })));
const selectedKey = ref<string | null>('m-dashboard');
const expandedKeys = ref<string[]>(['m-authz', 'm-system']);
const draft = ref<MenuEntry | null>(null);
const isCreating = ref(false);
function buildTree(list, parentId: string | null = null): TreeNode[] {
return list
.filter((m) => m.parentId === parentId)
.sort((a, b) => a.sort - b.sort)
.map<TreeNode>((m) => {
const children = buildTree(list, m.id);
const iconSpan = m.icon
? `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3" /></svg>`
: undefined;
return {
key: m.id,
label: m.title + (m.visible ? '' : ' (hidden)'),
icon: iconSpan,
children: children.length ? children : undefined,
};
});
}
const treeNodes = computed<TreeNode[]>(() => buildTree(menus.value));
function collectMenuDescendantIds(id): string[] {
const out= [];
const visit = (parentId) => {
for (const child of menus.value.filter((m) => m.parentId === parentId)) {
out.push(child.id);
visit(child.id);
}
};
visit(id);
return out;
}
function buildParentTree(parentId, disabledIds): TreeSelectNode[] {
return menus.value
.filter((m) => m.parentId === parentId)
.sort((a, b) => a.sort - b.sort)
.map((m) => ({
value: m.id,
label: m.title,
disabled: disabledIds.has(m.id),
children: buildParentTree(m.id, disabledIds),
}));
}
const parentTreeOptions = computed<TreeSelectNode[]>(() => {
const disabledIds = new Set<string>();
if (draft.value && !isCreating.value) {
disabledIds.add(draft.value.id);
for (const id of collectMenuDescendantIds(draft.value.id)) disabledIds.add(id);
}
return [{
value: '__root__',
label: t.value.menu_no_parent,
children: buildParentTree(null, disabledIds),
}];
});
const parentSelectValue = computed({
get: () => draft.value?.parentId ?? '__root__',
set: (value) => {
if (!draft.value || Array.isArray(value)) return;
draft.value.parentId = value && value !== '__root__' ? value : null;
},
});
watch(selectedKey, (key) => {
if (isCreating.value && key === null) return;
const found = key ? menus.value.find((m) => m.id === key) : null;
isCreating.value = false;
draft.value = found ? { ...found } : null;
}, { immediate: true });
function saveDraft() {
if (!draft.value) return;
const d = draft.value;
if (!d.title.trim()) {
toast.error(state.locale.value === 'zh' ? '名称必填' : 'Title is required');
return;
}
if (!isCreating.value) {
const disabledIds = new Set([d.id, ...collectMenuDescendantIds(d.id)]);
if (d.parentId && disabledIds.has(d.parentId)) {
toast.error(state.locale.value === 'zh' ? '不能选择自身或子级作为父级' : 'Cannot choose itself or a descendant');
return;
}
}
if (isCreating.value) {
menus.value = [...menus.value, { ...d }];
if (d.parentId) expandedKeys.value = [...new Set([...expandedKeys.value, d.parentId])];
selectedKey.value = d.id;
isCreating.value = false;
toast.success(state.locale.value === 'zh' ? '已新增菜单' : 'Menu created');
return;
}
menus.value = menus.value.map((m) => (m.id === d.id ? { ...d } : m));
if (d.parentId) expandedKeys.value = [...new Set([...expandedKeys.value, d.parentId])];
toast.success(state.locale.value === 'zh' ? '已更新' : 'Updated');
}
const confirmOpen = ref(false);
function askDelete() {
if (!selectedKey.value) return;
confirmOpen.value = true;
}
function confirmDelete() {
if (!selectedKey.value) return;
const removeIds = new Set<string>();
const queue = [selectedKey.value];
while (queue.length) {
const id = queue.shift()!;
removeIds.add(id);
for (const m of menus.value) if (m.parentId === id) queue.push(m.id);
}
menus.value = menus.value.filter((m) => !removeIds.has(m.id));
selectedKey.value = null;
draft.value = null;
toast.success(
state.locale.value === 'zh'
? `已删除 ${removeIds.size} 个菜单项`
: `Deleted ${removeIds.size} menu entries`,
);
}
function openCreate() {
const nextSort = menus.value.filter((m) => m.parentId === null).length + 1;
isCreating.value = true;
selectedKey.value = null;
draft.value = {
id: `m-${Date.now().toString(36)}`,
parentId: null,
title: state.locale.value === 'zh' ? '新菜单' : 'New menu',
icon: 'square',
route: '',
sort: nextSort,
visible: true,
};
}
function cancelCreate() {
isCreating.value = false;
selectedKey.value = menus.value[0]?.id ?? null;
}
</script>
<template>
<div class="adm-page">
<p class="adm-page__hint">{{ t.menu_hint }}</p>
<CfSplitter orientation="horizontal" :default-size="40" unit="%" class="adm-menus">
<template #start>
<div class="adm-menus__tree">
<header class="adm-menus__tree-head">
<CfButton size="sm" variant="primary" @click="openCreate">
+ {{ state.locale.value === 'zh' ? '新增菜单' : 'New menu' }}
</CfButton>
<CfButton
size="sm"
variant="danger"
:disabled="!selectedKey || isCreating"
@click="askDelete"
>
{{ t.delete }}
</CfButton>
</header>
<div class="adm-menus__tree-body">
<CfTreeView
:nodes="treeNodes"
selectable="single"
:selected-key="selectedKey"
v-model:expanded-keys="expandedKeys"
:show-line="true"
size="sm"
@update:selectedKey="(k) => (selectedKey = k ?? null)"
/>
</div>
</div>
</template>
<template #end>
<div class="adm-menus__editor">
<template v-if="draft">
<header class="adm-menus__editor-head">
<h3>{{ isCreating ? (state.locale.value === 'zh' ? '新增菜单' : 'New menu') : (draft.title || '—') }}</h3>
<span class="adm-menus__editor-meta">{{ draft.id }}</span>
</header>
<CfForm :model="draft" layout="vertical">
<CfFormField :label="t.menu_title" name="title">
<CfInput v-model="draft.title" />
</CfFormField>
<CfFormField :label="t.menu_icon" name="icon">
<CfIconPicker v-model="draft.icon" :placeholder="t.menu_icon" />
<div v-if="draft.icon" class="adm-menus__preview">
<CfIcon :name="draft.icon" />
<code>{{ draft.icon }}</code>
</div>
</CfFormField>
<CfFormField :label="t.menu_route" name="route">
<CfInput v-model="draft.route" placeholder="/example" />
</CfFormField>
<div class="adm-menus__row">
<CfFormField :label="t.menu_parent" name="parentId" style="flex: 1;">
<CfTreeSelect
v-model="parentSelectValue"
:options="parentTreeOptions"
searchable
size="sm"
:placeholder="t.menu_parent"
/>
</CfFormField>
<CfFormField :label="t.menu_sort" name="sort" style="width: 120px;">
<CfNumberInput v-model="draft.sort" :min="1" :step="1" />
</CfFormField>
</div>
<CfFormField :label="t.menu_visible" name="visible">
<CfSwitch v-model="draft.visible" />
</CfFormField>
</CfForm>
<footer class="adm-menus__editor-foot">
<CfButton v-if="isCreating" variant="tertiary" @click="cancelCreate">{{ t.cancel }}</CfButton>
<CfButton variant="primary" @click="saveDraft">{{ t.save }}</CfButton>
</footer>
</template>
<CfEmpty v-else :description="t.menu_select_to_edit" />
</div>
</template>
</CfSplitter>
<CfConfirmDialog
v-model:open="confirmOpen"
tone="warning"
:title="state.locale.value === 'zh' ? '确认删除?' : 'Delete this menu?'"
:description="state.locale.value === 'zh' ? '将连带删除所有子菜单。' : 'All descendant menu entries will be removed.'"
:ok-text="t.delete"
:cancel-text="t.cancel"
ok-variant="danger"
@ok="confirmDelete"
/>
</div>
</template>
<style scoped>
.adm-page { display: flex; flex-direction: column; gap: 12px; }
.adm-page__hint { margin: 0; color: var(--fg-3); font-size: var(--t-12); }
.adm-menus { min-height: 480px; }
.adm-menus__tree {
height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--bg-1);
border: 1px solid var(--line-1);
border-radius: var(--r-4);
padding: 10px;
}
.adm-menus__tree-head {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.adm-menus__tree-body {
flex: 1;
overflow: auto;
}
.adm-menus__editor {
height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
padding-left: 12px;
}
.adm-menus__editor-head {
display: flex;
align-items: baseline;
justify-content: space-between;
border-bottom: 1px solid var(--line-1);
padding-bottom: 8px;
}
.adm-menus__editor-head h3 {
margin: 0;
font-size: var(--t-14);
font-weight: var(--w-medium);
color: var(--fg-1);
}
.adm-menus__editor-meta {
color: var(--fg-3);
font-family: var(--font-mono);
font-size: var(--t-11);
}
.adm-menus__editor-foot {
display: flex;
gap: 8px;
justify-content: flex-end;
padding-top: 8px;
border-top: 1px solid var(--line-1);
}
.adm-menus__row {
display: flex;
gap: 12px;
}
.adm-menus__preview {
margin-top: 8px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--line-1);
border-radius: var(--r-4);
background: var(--bg-2);
color: var(--fg-2);
font-size: var(--t-12);
}
.adm-menus__preview code { font-family: var(--font-mono); font-size: var(--t-11); }
</style> import { useMemo, useState } from 'react';
import {
CfAppShell,
CfSidebar,
CfNavMenu,
CfBreadcrumb,
CfTag,
CfSearchInput,
CfIconButton,
CfPopover,
CfDropdown,
CfAvatar,
CfBadge,
CfDrawer,
CfSegmentedControl,
CfModal,
CfForm,
CfFormField,
CfInput,
CfPasswordStrength,
toast,
} from '@chufix-design/react';
import Dashboard from './pages/Dashboard';
import Users from './pages/Users';
import Roles from './pages/Roles';
import UserRoles from './pages/UserRoles';
import Dict from './pages/Dict';
import OperationLog from './pages/OperationLog';
import LoginLog from './pages/LoginLog';
import {
ACCENT_HUE,
DemoContext,
STRINGS,
type DemoTheme,
type DemoDensity,
type DemoMenuForm,
type DemoAccent,
type DemoLocale,
} from './state';
type RouteId = 'dashboard' | 'users' | 'roles' | 'user-roles' | 'dict' | 'op-log' | 'login-log';
export default function AdminMiniDemo() {
const [theme, setTheme] = useState<DemoTheme>('dark-cool');
const [density, setDensity] = useState<DemoDensity>('comfortable');
const [menuForm, setMenuForm] = useState<DemoMenuForm>('sidebar');
const [accent, setAccent] = useState<DemoAccent>('blue');
const [locale, setLocale] = useState<DemoLocale>('zh');
const [route, setRoute] = useState<RouteId>('dashboard');
const [settingsOpen, setSettingsOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
const [passwordOpen, setPasswordOpen] = useState(false);
const t = STRINGS[locale];
const sidebarItems = useMemo(() => [
{
type: 'group' as const,
label: t.grp_overview,
items: [{ key: 'dashboard', label: t.page_dashboard }],
},
{
type: 'group' as const,
label: t.grp_authz,
items: [
{ key: 'users', label: t.page_users },
{ key: 'roles', label: t.page_roles },
{ key: 'user-roles', label: t.page_user_roles },
],
},
{
type: 'group' as const,
label: t.grp_system,
items: [
{ key: 'dict', label: t.page_dict },
{ key: 'op-log', label: t.page_op_log },
{ key: 'login-log', label: t.page_login_log },
],
},
], [t]);
const pages: Record<RouteId, JSX.Element> = {
'dashboard': <Dashboard />,
'users': <Users />,
'roles': <Roles />,
'user-roles': <UserRoles />,
'dict': <Dict />,
'op-log': <OperationLog />,
'login-log': <LoginLog />,
};
const showSidebar = menuForm !== 'topbar';
const sidebarCollapsed = menuForm === 'collapsed';
function onLogout() {
toast.info(t.logout_done);
}
return (
<DemoContext.Provider
value={{ theme, setTheme, density, setDensity, menuForm, setMenuForm, accent, setAccent, locale, setLocale }}
>
<div
className="adm-root"
data-theme={theme}
data-density={density}
style={{ ['--accent-1' as never]: ACCENT_HUE[accent] }}
>
<CfAppShell
sidebarCollapsed={sidebarCollapsed}
sidebarWidth={sidebarCollapsed ? 64 : 220}
headerHeight={56}
header={
<AdminHeader
localeLabel={locale === 'zh' ? '中' : 'EN'}
onToggleLocale={() => setLocale((l) => (l === 'zh' ? 'en' : 'zh'))}
onOpenSettings={() => setSettingsOpen(true)}
onOpenProfile={() => setProfileOpen(true)}
onOpenChangePassword={() => setPasswordOpen(true)}
onLogout={onLogout}
t={t}
/>
}
sidebar={
showSidebar ? (
<CfSidebar
items={sidebarItems}
modelValue={route}
collapsed={sidebarCollapsed}
onUpdateModelValue={(key) => setRoute(key as RouteId)}
/>
) : undefined
}
>
<section className="adm-body">
{menuForm === 'topbar' && (
<CfNavMenu
items={sidebarItems.flatMap((g) => g.items.map((i) => ({ key: i.key, label: i.label, href: '#' + i.key })))}
active={route}
variant="underline"
className="adm-body__nav"
onNavigate={(item) => setRoute(item.key as RouteId)}
/>
)}
<CfBreadcrumb items={[{ label: t.brand }, { label: sidebarItems.flatMap((g) => g.items).find((i) => i.key === route)?.label ?? '' }]} />
<h2 className="adm-body__title">{sidebarItems.flatMap((g) => g.items).find((i) => i.key === route)?.label}</h2>
{pages[route]}
</section>
</CfAppShell>
<SettingsDrawer open={settingsOpen} onOpenChange={setSettingsOpen} />
<ProfileModal open={profileOpen} onOpenChange={setProfileOpen} />
<ChangePasswordModal open={passwordOpen} onOpenChange={setPasswordOpen} />
</div>
</DemoContext.Provider>
);
}
/* ============================================================ */
/* AdminHeader */
/* ============================================================ */
interface AdminHeaderProps {
localeLabel: string;
onToggleLocale: () => void;
onOpenSettings: () => void;
onOpenProfile: () => void;
onOpenChangePassword: () => void;
onLogout: () => void;
t: (typeof STRINGS)[DemoLocale];
}
function AdminHeader({
localeLabel,
onToggleLocale,
onOpenSettings,
onOpenProfile,
onOpenChangePassword,
onLogout,
t,
}: AdminHeaderProps) {
const [search, setSearch] = useState('');
const [notifs, setNotifs] = useState([
{ id: 1, title: '用户 ada 提交了新角色申请', from: 'system', at: '2 分钟前', unread: true },
{ id: 2, title: '操作日志:grace 导出了登录日志', from: 'audit', at: '8 分钟前', unread: true },
{ id: 3, title: '字典「日志级别」新增 DEBUG 项', from: 'admin', at: '32 分钟前', unread: false },
{ id: 4, title: '今日凌晨完成数据库自动备份', from: 'cron', at: '昨天', unread: false },
]);
const unreadCount = notifs.filter((n) => n.unread).length;
const userMenuItems = [
{ key: 'user-info', label: 'Admin · [email protected]', header: true },
{ key: 'divider-1', divider: true },
{ key: 'profile', label: t.profile },
{ key: 'change-password', label: t.change_password },
{ key: 'divider-2', divider: true },
{ key: 'logout', label: t.logout, tone: 'danger' as const },
];
return (
<div className="adm-header">
<div className="adm-header__brand">
<span className="adm-header__logo" />
<span className="adm-header__title">{t.brand}</span>
<CfTag size="sm" tone="info" variant="soft">demo</CfTag>
</div>
<div className="adm-header__center">
<CfSearchInput
modelValue={search}
onUpdateModelValue={setSearch}
placeholder={t.search_placeholder}
size="sm"
/>
</div>
<div className="adm-header__actions">
<CfPopover placement="bottom" width={320}>
<button className="adm-iconbtn" type="button" aria-label={t.notifications}>
<CfBadge count={unreadCount} max={99} dot={false} showZero={false} placement="top-right">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 8a6 6 0 1112 0c0 6 3 7 3 7H3s3-1 3-7" />
<path d="M10 19a2 2 0 004 0" />
</svg>
</CfBadge>
</button>
<CfPopover.Content>
<div className="adm-notif">
<header className="adm-notif__head">
<span className="adm-notif__title">{t.notifications}</span>
<button
type="button"
className="adm-notif__link"
onClick={() => setNotifs((ns) => ns.map((n) => ({ ...n, unread: false })))}
>
{t.mark_all_read}
</button>
</header>
{notifs.length ? (
<ul className="adm-notif__list">
{notifs.map((n) => (
<li
key={n.id}
className={`adm-notif__item${n.unread ? ' is-unread' : ''}`}
>
<span className="adm-notif__dot" />
<div className="adm-notif__body">
<div className="adm-notif__text">{n.title}</div>
<div className="adm-notif__meta">{n.from} · {n.at}</div>
</div>
</li>
))}
</ul>
) : (
<div className="adm-notif__empty">{t.no_notifications}</div>
)}
</div>
</CfPopover.Content>
</CfPopover>
<button
className="adm-iconbtn adm-iconbtn--lang"
type="button"
aria-label={t.switch_locale}
onClick={onToggleLocale}
>
{localeLabel}
</button>
<button className="adm-iconbtn" type="button" aria-label={t.settings} onClick={onOpenSettings}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09a1.65 1.65 0 00-1-1.51 1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09a1.65 1.65 0 001.51-1 1.65 1.65 0 00-.33-1.82l-.06-.06A2 2 0 017.04 4.04l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9c.36.94 1.18 1.5 2.15 1.51H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
</button>
<CfDropdown
items={userMenuItems}
placement="bottom"
width={200}
onSelect={(item) => {
if (item.key === 'profile') onOpenProfile();
else if (item.key === 'change-password') onOpenChangePassword();
else if (item.key === 'logout') onLogout();
}}
>
<button className="adm-user" type="button">
<CfAvatar name="Admin" size="sm" />
<span className="adm-user__name">Admin</span>
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4">
<path d="M4 6l4 4 4-4" />
</svg>
</button>
</CfDropdown>
</div>
</div>
);
}
/* ============================================================ */
/* SettingsDrawer */
/* ============================================================ */
interface SettingsDrawerProps { open: boolean; onOpenChange: (v: boolean) => void }
function SettingsDrawer({ open, onOpenChange }: SettingsDrawerProps) {
// 简化引用:实际实现中读取 DemoContext。这里仅展示结构。
return (
<CfDrawer
open={open}
placement="right"
size="sm"
title="主题设置"
onUpdateOpen={onOpenChange}
>
<div className="adm-settings">
{/* theme / density / menu form / accent CfSegmentedControl + accent dots */}
</div>
</CfDrawer>
);
}
/* ============================================================ */
/* ProfileModal */
/* ============================================================ */
interface ProfileModalProps { open: boolean; onOpenChange: (v: boolean) => void }
function ProfileModal({ open, onOpenChange }: ProfileModalProps) {
const [form, setForm] = useState({ name: '系统管理员', email: '[email protected]', phone: '13800000001' });
return (
<CfModal
open={open}
onUpdateOpen={onOpenChange}
title="个人中心"
size="md"
okText="保存"
cancelText="取消"
onBeforeOk={() => {
toast.success('资料已保存');
return true;
}}
>
<CfForm model={form} layout="vertical">
<CfFormField label="姓名" name="name">
<CfInput modelValue={form.name} onUpdateModelValue={(v) => setForm({ ...form, name: v })} />
</CfFormField>
<CfFormField label="邮箱" name="email">
<CfInput modelValue={form.email} onUpdateModelValue={(v) => setForm({ ...form, email: v })} />
</CfFormField>
<CfFormField label="手机号" name="phone">
<CfInput modelValue={form.phone} onUpdateModelValue={(v) => setForm({ ...form, phone: v })} />
</CfFormField>
</CfForm>
</CfModal>
);
}
/* ============================================================ */
/* ChangePasswordModal */
/* ============================================================ */
interface ChangePasswordModalProps { open: boolean; onOpenChange: (v: boolean) => void }
function ChangePasswordModal({ open, onOpenChange }: ChangePasswordModalProps) {
const [form, setForm] = useState({ current: '', next: '', confirm: '' });
return (
<CfModal
open={open}
onUpdateOpen={onOpenChange}
title="修改密码"
size="sm"
okText="保存"
cancelText="取消"
onBeforeOk={() => {
if (!form.current || !form.next || !form.confirm) {
toast.error('请填写完整');
return false;
}
if (form.next !== form.confirm) {
toast.error('两次输入的新密码不一致');
return false;
}
toast.success('密码已更新');
return true;
}}
>
<CfForm model={form} layout="vertical">
<CfFormField label="当前密码" name="current">
<CfInput modelValue={form.current} type="password" onUpdateModelValue={(v) => setForm({ ...form, current: v })} />
</CfFormField>
<CfFormField label="新密码" name="next">
<CfInput modelValue={form.next} type="password" onUpdateModelValue={(v) => setForm({ ...form, next: v })} />
{form.next ? <CfPasswordStrength value={form.next} size="sm" style={{ marginTop: 6 }} /> : null}
</CfFormField>
<CfFormField label="确认新密码" name="confirm">
<CfInput modelValue={form.confirm} type="password" onUpdateModelValue={(v) => setForm({ ...form, confirm: v })} />
</CfFormField>
</CfForm>
</CfModal>
);
} // admin-mini 演示的全局 UI 状态:主题 / 密度 / 菜单形态 / accent 色 / 语言。
// React 端用 Context 把状态下发到所有 page。
import { createContext, type Dispatch, type SetStateAction } from 'react';
export type DemoTheme = 'dark-cool' | 'dark-warm' | 'light';
export type DemoDensity = 'comfortable' | 'compact';
export type DemoMenuForm = 'sidebar' | 'topbar' | 'collapsed';
export type DemoAccent = 'blue' | 'green' | 'purple' | 'orange' | 'rose';
export type DemoLocale = 'zh' | 'en';
export interface DemoState {
theme: DemoTheme;
setTheme: Dispatch<SetStateAction<DemoTheme>>;
density: DemoDensity;
setDensity: Dispatch<SetStateAction<DemoDensity>>;
menuForm: DemoMenuForm;
setMenuForm: Dispatch<SetStateAction<DemoMenuForm>>;
accent: DemoAccent;
setAccent: Dispatch<SetStateAction<DemoAccent>>;
locale: DemoLocale;
setLocale: Dispatch<SetStateAction<DemoLocale>>;
}
export const DemoContext = createContext<DemoState | null>(null);
export const ACCENT_HUE: Record<DemoAccent, string> = {
blue: 'oklch(64% 0.16 263)',
green: 'oklch(68% 0.16 150)',
purple: 'oklch(64% 0.18 300)',
orange: 'oklch(72% 0.16 60)',
rose: 'oklch(66% 0.18 15)',
};
export interface DemoStrings {
brand: string;
switch_theme: string;
switch_density: string;
switch_menu: string;
switch_accent: string;
switch_locale: string;
view_source: string;
hide_source: string;
page_dashboard: string;
page_users: string;
page_roles: string;
page_user_roles: string;
page_dict: string;
page_op_log: string;
page_login_log: string;
grp_overview: string;
grp_authz: string;
grp_system: string;
search: string;
create: string;
edit: string;
delete: string;
save: string;
cancel: string;
status: string;
status_active: string;
status_disabled: string;
status_ok: string;
status_fail: string;
status_success: string;
status_failed: string;
actions: string;
total_rows: string;
col_id: string;
col_username: string;
col_name: string;
col_email: string;
col_phone: string;
col_created_at: string;
col_role_name: string;
col_role_desc: string;
col_role_perms: string;
col_user_roles: string;
col_assign: string;
col_dict_label: string;
col_dict_value: string;
col_dict_remark: string;
col_log_user: string;
col_log_action: string;
col_log_resource: string;
col_log_ip: string;
col_log_at: string;
col_log_ua: string;
kpi_users: string;
kpi_roles: string;
kpi_login_today: string;
kpi_op_today: string;
recent_op: string;
recent_login: string;
}
export const STRINGS: Record<DemoLocale, DemoStrings> = {
zh: {
brand: 'admin-mini 后台',
switch_theme: '主题', switch_density: '密度', switch_menu: '菜单形态',
switch_accent: '主色', switch_locale: '语言',
view_source: '查看源码', hide_source: '关闭源码',
page_dashboard: '工作台', page_users: '用户管理', page_roles: '角色管理',
page_user_roles: '用户角色', page_dict: '字典管理',
page_op_log: '操作日志', page_login_log: '登录日志',
grp_overview: '概览', grp_authz: '权限', grp_system: '系统',
search: '搜索', create: '新增', edit: '编辑', delete: '删除',
save: '保存', cancel: '取消',
status: '状态', status_active: '启用', status_disabled: '停用',
status_ok: '成功', status_fail: '失败',
status_success: '成功', status_failed: '失败',
actions: '操作', total_rows: '共 {n} 条',
col_id: 'ID', col_username: '账号', col_name: '姓名',
col_email: '邮箱', col_phone: '手机号', col_created_at: '创建时间',
col_role_name: '角色名', col_role_desc: '描述', col_role_perms: '权限',
col_user_roles: '已分配角色', col_assign: '分配角色',
col_dict_label: '名称', col_dict_value: '值', col_dict_remark: '备注',
col_log_user: '操作人', col_log_action: '动作', col_log_resource: '资源',
col_log_ip: 'IP', col_log_at: '时间', col_log_ua: '设备',
kpi_users: '用户总数', kpi_roles: '角色总数',
kpi_login_today: '今日登录', kpi_op_today: '今日操作',
recent_op: '最近操作', recent_login: '最近登录',
},
en: {
brand: 'admin-mini console',
switch_theme: 'Theme', switch_density: 'Density', switch_menu: 'Menu',
switch_accent: 'Accent', switch_locale: 'Locale',
view_source: 'View source', hide_source: 'Hide source',
page_dashboard: 'Dashboard', page_users: 'Users', page_roles: 'Roles',
page_user_roles: 'User roles', page_dict: 'Dictionary',
page_op_log: 'Operation log', page_login_log: 'Login log',
grp_overview: 'Overview', grp_authz: 'Authorization', grp_system: 'System',
search: 'Search', create: 'Create', edit: 'Edit', delete: 'Delete',
save: 'Save', cancel: 'Cancel',
status: 'Status', status_active: 'Active', status_disabled: 'Disabled',
status_ok: 'OK', status_fail: 'Failed',
status_success: 'Success', status_failed: 'Failed',
actions: 'Actions', total_rows: '{n} rows',
col_id: 'ID', col_username: 'Username', col_name: 'Name',
col_email: 'Email', col_phone: 'Phone', col_created_at: 'Created at',
col_role_name: 'Role', col_role_desc: 'Description', col_role_perms: 'Permissions',
col_user_roles: 'Assigned roles', col_assign: 'Assign',
col_dict_label: 'Label', col_dict_value: 'Value', col_dict_remark: 'Remark',
col_log_user: 'User', col_log_action: 'Action', col_log_resource: 'Resource',
col_log_ip: 'IP', col_log_at: 'Time', col_log_ua: 'Device',
kpi_users: 'Total users', kpi_roles: 'Total roles',
kpi_login_today: "Today's logins", kpi_op_today: "Today's operations",
recent_op: 'Recent operations', recent_login: 'Recent logins',
},
}; // admin-mini 演示用的内存 mock 数据,不接任何后端。
export interface AdminUser {
id: number;
username: string;
name: string;
email: string;
phone: string;
status: 'active' | 'disabled';
createdAt: string;
}
export interface AdminRole {
id: string;
name: string;
description: string;
permissions: string[];
}
export interface UserRoleLink {
userId: number;
roleIds: string[];
}
export interface DictItem {
id: string;
parentId: string | null;
label: string;
value: string;
remark: string;
}
export interface OperationLog {
id: number;
user: string;
action: string;
resource: string;
ip: string;
at: string;
status: 'ok' | 'fail';
}
export interface LoginLog {
id: number;
user: string;
ip: string;
ua: string;
at: string;
status: 'success' | 'failed';
}
export const initialUsers: AdminUser[] = [
{ id: 1, username: 'admin', name: '系统管理员', email: '[email protected]', phone: '13800000001', status: 'active', createdAt: '2025-09-01 10:21' },
{ id: 2, username: 'ada', name: 'Ada Lovelace', email: '[email protected]', phone: '13800000002', status: 'active', createdAt: '2025-10-12 14:03' },
{ id: 3, username: 'linus', name: 'Linus Torvalds', email: '[email protected]', phone: '13800000003', status: 'active', createdAt: '2025-11-03 09:47' },
{ id: 4, username: 'grace', name: 'Grace Hopper', email: '[email protected]', phone: '13800000004', status: 'disabled', createdAt: '2025-11-19 16:55' },
{ id: 5, username: 'alan', name: 'Alan Turing', email: '[email protected]', phone: '13800000005', status: 'active', createdAt: '2026-01-08 11:32' },
{ id: 6, username: 'donald',name: 'Donald Knuth', email: '[email protected]',phone: '13800000006', status: 'active', createdAt: '2026-02-14 13:18' },
];
export const initialRoles: AdminRole[] = [
{ id: 'r-admin', name: '超级管理员', description: '拥有全部权限', permissions: ['user:*', 'role:*', 'dict:*', 'log:*'] },
{ id: 'r-manager', name: '业务管理员', description: '业务模块全部读写', permissions: ['user:read', 'user:write', 'role:read'] },
{ id: 'r-viewer', name: '只读账号', description: '只能查看,不能修改', permissions: ['user:read', 'role:read', 'log:read'] },
{ id: 'r-auditor', name: '审计员', description: '查看日志,不能修改业务', permissions: ['log:read', 'log:export'] },
];
export const initialUserRoles: UserRoleLink[] = [
{ userId: 1, roleIds: ['r-admin'] },
{ userId: 2, roleIds: ['r-manager'] },
{ userId: 3, roleIds: ['r-manager', 'r-auditor'] },
{ userId: 4, roleIds: ['r-viewer'] },
{ userId: 5, roleIds: ['r-viewer'] },
{ userId: 6, roleIds: ['r-auditor'] },
];
export const initialDict: DictItem[] = [
{ id: 'd-status', parentId: null, label: '用户状态', value: 'user_status', remark: '系统内置' },
{ id: 'd-status-1', parentId: 'd-status', label: '启用', value: 'active', remark: '' },
{ id: 'd-status-2', parentId: 'd-status', label: '停用', value: 'disabled', remark: '' },
{ id: 'd-gender', parentId: null, label: '性别', value: 'gender', remark: '' },
{ id: 'd-gender-1', parentId: 'd-gender', label: '男', value: 'M', remark: '' },
{ id: 'd-gender-2', parentId: 'd-gender', label: '女', value: 'F', remark: '' },
{ id: 'd-gender-3', parentId: 'd-gender', label: '保密', value: 'X', remark: '' },
{ id: 'd-level', parentId: null, label: '日志级别', value: 'log_level', remark: '' },
{ id: 'd-level-1', parentId: 'd-level', label: 'INFO', value: 'info', remark: '' },
{ id: 'd-level-2', parentId: 'd-level', label: 'WARN', value: 'warn', remark: '' },
{ id: 'd-level-3', parentId: 'd-level', label: 'ERROR', value: 'error', remark: '' },
];
export const initialOpLogs: OperationLog[] = [
{ id: 1, user: 'admin', action: 'create', resource: '/api/users', ip: '10.1.0.21', at: '2026-05-12 08:21:03', status: 'ok' },
{ id: 2, user: 'ada', action: 'update', resource: '/api/roles/r-manager', ip: '10.1.0.22', at: '2026-05-12 08:19:51', status: 'ok' },
{ id: 3, user: 'linus', action: 'delete', resource: '/api/users/9', ip: '10.1.0.23', at: '2026-05-12 08:17:22', status: 'fail' },
{ id: 4, user: 'ada', action: 'login', resource: '/auth/login', ip: '10.1.0.22', at: '2026-05-12 08:14:08', status: 'ok' },
{ id: 5, user: 'grace', action: 'export', resource: '/api/logs/op', ip: '10.1.0.42', at: '2026-05-12 08:10:42', status: 'ok' },
{ id: 6, user: 'admin', action: 'update', resource: '/api/dict/d-status', ip: '10.1.0.21', at: '2026-05-12 08:02:13', status: 'ok' },
];
export const initialLoginLogs: LoginLog[] = [
{ id: 1, user: 'admin', ip: '10.1.0.21', ua: 'Chrome 124 / macOS', at: '2026-05-12 08:14:08', status: 'success' },
{ id: 2, user: 'ada', ip: '10.1.0.22', ua: 'Edge 123 / Windows', at: '2026-05-12 08:11:55', status: 'success' },
{ id: 3, user: 'linus', ip: '10.1.0.23', ua: 'Firefox 124 / Linux', at: '2026-05-12 08:09:31', status: 'success' },
{ id: 4, user: 'grace', ip: '203.0.113.6', ua: 'Safari 17 / iOS', at: '2026-05-12 08:07:18', status: 'failed' },
{ id: 5, user: 'grace', ip: '203.0.113.6', ua: 'Safari 17 / iOS', at: '2026-05-12 08:06:42', status: 'failed' },
{ id: 6, user: 'alan', ip: '10.1.0.24', ua: 'Chrome 124 / Linux', at: '2026-05-12 07:58:11', status: 'success' },
]; import { useContext } from 'react';
import { CfStat, CfTable, CfTag } from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import {
initialUsers,
initialRoles,
initialOpLogs,
initialLoginLogs,
type OperationLog,
type LoginLog,
} from '../mock';
export default function Dashboard() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const kpis = [
{ label: t.kpi_users, value: initialUsers.length, trend: { delta: '+2', direction: 'up' as const } },
{ label: t.kpi_roles, value: initialRoles.length, trend: { delta: '+1', direction: 'up' as const } },
{ label: t.kpi_login_today, value: 38, trend: { delta: '+12%', direction: 'up' as const } },
{ label: t.kpi_op_today, value: 142, trend: { delta: '-4%', direction: 'down' as const } },
];
const opCols = [
{ key: 'user', title: t.col_log_user, dataIndex: 'user', width: 100 },
{ key: 'action', title: t.col_log_action, dataIndex: 'action', width: 90 },
{ key: 'resource', title: t.col_log_resource, dataIndex: 'resource' },
{ key: 'at', title: t.col_log_at, dataIndex: 'at', width: 180 },
];
const loginCols = [
{ key: 'user', title: t.col_log_user, dataIndex: 'user', width: 100 },
{ key: 'ip', title: t.col_log_ip, dataIndex: 'ip', width: 130 },
{ key: 'ua', title: t.col_log_ua, dataIndex: 'ua' },
{ key: 'at', title: t.col_log_at, dataIndex: 'at', width: 180 },
];
return (
<div className="adm-dashboard">
<div className="adm-dashboard__kpis">
{kpis.map((k) => (
<CfStat key={k.label} label={k.label} value={k.value} trend={k.trend} variant="outlined" />
))}
</div>
<div className="adm-dashboard__pair">
<section className="adm-card">
<header className="adm-card__head">
<h3>{t.recent_op}</h3>
<CfTag size="sm" tone="info">live</CfTag>
</header>
<CfTable columns={opCols} rows={initialOpLogs.slice(0, 5)} rowKey={(r: OperationLog) => String(r.id)} size="sm" />
</section>
<section className="adm-card">
<header className="adm-card__head">
<h3>{t.recent_login}</h3>
<CfTag size="sm" tone="success">stable</CfTag>
</header>
<CfTable columns={loginCols} rows={initialLoginLogs.slice(0, 5)} rowKey={(r: LoginLog) => String(r.id)} size="sm" />
</section>
</div>
</div>
);
} import { useContext, useMemo, useState } from 'react';
import {
CfTable,
CfTag,
CfButton,
CfModal,
CfForm,
CfFormField,
CfInput,
CfSelect,
CfSearchInput,
} from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import { initialUsers, type AdminUser } from '../mock';
type Draft = Pick<AdminUser, 'username' | 'name' | 'email' | 'phone' | 'status'>;
export default function Users() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const [rows, setRows] = useState<AdminUser[]>(initialUsers);
const [search, setSearch] = useState('');
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<AdminUser | null>(null);
const [form, setForm] = useState<Draft>({ username: '', name: '', email: '', phone: '', status: 'active' });
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return rows;
return rows.filter((r) =>
[r.username, r.name, r.email, r.phone].some((v) => v.toLowerCase().includes(q)),
);
}, [rows, search]);
function openCreate() {
setEditing(null);
setForm({ username: '', name: '', email: '', phone: '', status: 'active' });
setOpen(true);
}
function openEdit(row: AdminUser) {
setEditing(row);
setForm({ username: row.username, name: row.name, email: row.email, phone: row.phone, status: row.status });
setOpen(true);
}
function remove(row: AdminUser) {
setRows((rs) => rs.filter((r) => r.id !== row.id));
}
function save() {
if (editing) {
const id = editing.id;
setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...form } : r)));
} else {
const nextId = (rows.reduce((m, r) => Math.max(m, r.id), 0) || 0) + 1;
setRows((rs) => [
...rs,
{ id: nextId, ...form, createdAt: new Date().toISOString().slice(0, 16).replace('T', ' ') },
]);
}
setOpen(false);
}
const cols = [
{ key: 'id', title: t.col_id, dataIndex: 'id', width: 60 },
{ key: 'username', title: t.col_username, dataIndex: 'username', width: 110 },
{ key: 'name', title: t.col_name, dataIndex: 'name', width: 140 },
{ key: 'email', title: t.col_email, dataIndex: 'email' },
{ key: 'phone', title: t.col_phone, dataIndex: 'phone', width: 130 },
{
key: 'status', title: t.status, dataIndex: 'status', width: 90,
render: (v: unknown) =>
<CfTag size="sm" tone={v === 'active' ? 'success' : 'danger'} variant="soft">
{v === 'active' ? t.status_active : t.status_disabled}
</CfTag>,
},
{ key: 'createdAt', title: t.col_created_at, dataIndex: 'createdAt', width: 160 },
{
key: 'actions', title: t.actions, dataIndex: 'id', width: 130, align: 'right' as const,
render: (_v: unknown, row: AdminUser) =>
<div style={{ display: 'inline-flex', gap: 4, justifyContent: 'flex-end' }}>
<CfButton size="sm" variant="tertiary" onClick={() => openEdit(row)}>{t.edit}</CfButton>
<CfButton size="sm" variant="danger" onClick={() => remove(row)}>{t.delete}</CfButton>
</div>,
},
];
return (
<div className="adm-page">
<header className="adm-page__head">
<div className="adm-page__head-left">
<CfSearchInput modelValue={search} onUpdateModelValue={setSearch} placeholder={t.search} size="sm" style={{ width: 220 }} />
<span className="adm-page__count">{t.total_rows.replace('{n}', String(filtered.length))}</span>
</div>
<CfButton variant="primary" size="sm" onClick={openCreate}>+ {t.create}</CfButton>
</header>
<CfTable columns={cols} rows={filtered} rowKey={(r: AdminUser) => String(r.id)} size="sm" />
<CfModal
open={open}
onUpdateOpen={setOpen}
title={editing ? t.edit : t.create}
okText={t.save}
cancelText={t.cancel}
onBeforeOk={() => { save(); return true; }}
size="md"
>
<CfForm model={form} layout="vertical">
<CfFormField label={t.col_username} name="username">
<CfInput modelValue={form.username} onUpdateModelValue={(v) => setForm({ ...form, username: v })} />
</CfFormField>
<CfFormField label={t.col_name} name="name">
<CfInput modelValue={form.name} onUpdateModelValue={(v) => setForm({ ...form, name: v })} />
</CfFormField>
<CfFormField label={t.col_email} name="email">
<CfInput modelValue={form.email} type="email" onUpdateModelValue={(v) => setForm({ ...form, email: v })} />
</CfFormField>
<CfFormField label={t.col_phone} name="phone">
<CfInput modelValue={form.phone} onUpdateModelValue={(v) => setForm({ ...form, phone: v })} />
</CfFormField>
<CfFormField label={t.status} name="status">
<CfSelect
modelValue={form.status}
onUpdateModelValue={(v) => setForm({ ...form, status: v as AdminUser['status'] })}
options={[
{ value: 'active', label: t.status_active },
{ value: 'disabled', label: t.status_disabled },
]}
/>
</CfFormField>
</CfForm>
</CfModal>
</div>
);
} import { useContext } from 'react';
import { CfTable, CfTag } from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import { initialRoles, type AdminRole } from '../mock';
export default function Roles() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const cols = [
{ key: 'name', title: t.col_role_name, dataIndex: 'name', width: 160 },
{ key: 'description', title: t.col_role_desc, dataIndex: 'description' },
{
key: 'permissions', title: t.col_role_perms, dataIndex: 'permissions',
render: (v: unknown) => (
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>
{(v as string[]).map((p) => (
<CfTag key={p} size="sm" variant="outline" tone="primary">{p}</CfTag>
))}
</div>
),
},
];
return (
<div className="adm-page">
<CfTable columns={cols} rows={initialRoles} rowKey={(r: AdminRole) => r.id} size="sm" />
</div>
);
} import { useContext, useState } from 'react';
import { CfTable, CfTag, CfButton, CfModal, CfCheckbox } from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import {
initialUsers,
initialRoles,
initialUserRoles,
type AdminUser,
type UserRoleLink,
} from '../mock';
export default function UserRoles() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const [links, setLinks] = useState<UserRoleLink[]>(
initialUserRoles.map((l) => ({ ...l, roleIds: [...l.roleIds] })),
);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<AdminUser | null>(null);
const [draft, setDraft] = useState<Set<string>>(new Set());
function rolesFor(userId: number): string[] {
return links.find((l) => l.userId === userId)?.roleIds ?? [];
}
function roleName(id: string): string {
return initialRoles.find((r) => r.id === id)?.name ?? id;
}
function openAssign(u: AdminUser) {
setEditing(u);
setDraft(new Set(rolesFor(u.id)));
setOpen(true);
}
function toggleRole(roleId: string) {
setDraft((prev) => {
const next = new Set(prev);
if (next.has(roleId)) next.delete(roleId);
else next.add(roleId);
return next;
});
}
function saveAssign() {
if (!editing) return;
const uid = editing.id;
const next = [...draft];
setLinks((ls) =>
ls.some((l) => l.userId === uid)
? ls.map((l) => (l.userId === uid ? { ...l, roleIds: next } : l))
: [...ls, { userId: uid, roleIds: next }],
);
setOpen(false);
}
const cols = [
{ key: 'username', title: t.col_username, dataIndex: 'username', width: 110 },
{ key: 'name', title: t.col_name, dataIndex: 'name', width: 140 },
{
key: 'roles', title: t.col_user_roles, dataIndex: 'id',
render: (v: unknown) => {
const ids = rolesFor(v as number);
if (!ids.length) return <span style={{ color: 'var(--fg-3)' }}>—</span>;
return (
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>
{ids.map((id) => (
<CfTag key={id} size="sm" variant="soft" tone="primary">{roleName(id)}</CfTag>
))}
</div>
);
},
},
{
key: 'assign', title: t.col_assign, dataIndex: 'id', width: 110, align: 'right' as const,
render: (_v: unknown, row: AdminUser) =>
<CfButton size="sm" variant="tertiary" onClick={() => openAssign(row)}>{t.edit}</CfButton>,
},
];
return (
<div className="adm-page">
<CfTable columns={cols} rows={initialUsers} rowKey={(r: AdminUser) => String(r.id)} size="sm" />
<CfModal
open={open}
onUpdateOpen={setOpen}
title={editing ? `${editing.name} · ${t.col_assign}` : t.col_assign}
okText={t.save}
cancelText={t.cancel}
onBeforeOk={() => { saveAssign(); return true; }}
size="sm"
>
<div className="adm-assign">
{initialRoles.map((r) => (
<label key={r.id} className="adm-assign__row">
<CfCheckbox modelValue={draft.has(r.id)} onUpdateModelValue={() => toggleRole(r.id)} />
<span className="adm-assign__name">{r.name}</span>
<span className="adm-assign__desc">{r.description}</span>
</label>
))}
</div>
</CfModal>
</div>
);
} import { useContext, useMemo } from 'react';
import { CfTable } from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import { initialDict, type DictItem } from '../mock';
interface DictNode extends DictItem { children?: DictNode[] }
export default function Dict() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const tree = useMemo<DictNode[]>(() => {
const byParent = new Map<string | null, DictItem[]>();
for (const item of initialDict) {
const arr = byParent.get(item.parentId) ?? [];
arr.push(item);
byParent.set(item.parentId, arr);
}
const roots = byParent.get(null) ?? [];
return roots.map((r) => ({ ...r, children: byParent.get(r.id) ?? [] }));
}, []);
const cols = [
{ key: 'label', title: t.col_dict_label, dataIndex: 'label', width: 220 },
{ key: 'value', title: t.col_dict_value, dataIndex: 'value', width: 200 },
{ key: 'remark', title: t.col_dict_remark, dataIndex: 'remark' },
];
return (
<div className="adm-page">
<CfTable
columns={cols}
rows={tree}
rowKey={(r: DictNode) => r.id}
defaultExpandedRowKeys={tree.map((n) => n.id)}
size="sm"
/>
</div>
);
} import { useContext, useMemo, useState } from 'react';
import { CfTable, CfTag, CfSearchInput } from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import { initialOpLogs, type OperationLog as OpLog } from '../mock';
export default function OperationLog() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return initialOpLogs;
return initialOpLogs.filter((r) =>
[r.user, r.action, r.resource, r.ip].some((v) => v.toLowerCase().includes(q)),
);
}, [search]);
const cols = [
{ key: 'user', title: t.col_log_user, dataIndex: 'user', width: 110 },
{ key: 'action', title: t.col_log_action, dataIndex: 'action', width: 100 },
{ key: 'resource', title: t.col_log_resource, dataIndex: 'resource' },
{ key: 'ip', title: t.col_log_ip, dataIndex: 'ip', width: 130 },
{
key: 'status', title: t.status, dataIndex: 'status', width: 90,
render: (v: unknown) =>
<CfTag size="sm" tone={v === 'ok' ? 'success' : 'danger'} variant="soft">
{v === 'ok' ? t.status_ok : t.status_fail}
</CfTag>,
},
{ key: 'at', title: t.col_log_at, dataIndex: 'at', width: 180 },
];
return (
<div className="adm-page">
<header className="adm-page__head">
<CfSearchInput modelValue={search} onUpdateModelValue={setSearch} placeholder={t.search} size="sm" style={{ width: 220 }} />
<span className="adm-page__count">{t.total_rows.replace('{n}', String(filtered.length))}</span>
</header>
<CfTable columns={cols} rows={filtered} rowKey={(r: OpLog) => String(r.id)} size="sm" />
</div>
);
} import { useContext, useMemo, useState } from 'react';
import { CfTable, CfTag, CfSearchInput } from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import { initialLoginLogs, type LoginLog as LL } from '../mock';
export default function LoginLog() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return initialLoginLogs;
return initialLoginLogs.filter((r) =>
[r.user, r.ip, r.ua].some((v) => v.toLowerCase().includes(q)),
);
}, [search]);
const cols = [
{ key: 'user', title: t.col_log_user, dataIndex: 'user', width: 110 },
{ key: 'ip', title: t.col_log_ip, dataIndex: 'ip', width: 140 },
{ key: 'ua', title: t.col_log_ua, dataIndex: 'ua' },
{
key: 'status', title: t.status, dataIndex: 'status', width: 90,
render: (v: unknown) =>
<CfTag size="sm" tone={v === 'success' ? 'success' : 'danger'} variant="soft">
{v === 'success' ? t.status_success : t.status_failed}
</CfTag>,
},
{ key: 'at', title: t.col_log_at, dataIndex: 'at', width: 180 },
];
return (
<div className="adm-page">
<header className="adm-page__head">
<CfSearchInput modelValue={search} onUpdateModelValue={setSearch} placeholder={t.search} size="sm" style={{ width: 220 }} />
<span className="adm-page__count">{t.total_rows.replace('{n}', String(filtered.length))}</span>
</header>
<CfTable columns={cols} rows={filtered} rowKey={(r: LL) => String(r.id)} size="sm" />
</div>
);
} import { useContext, useState } from 'react';
import {
CfTabs,
CfTabPanel,
CfForm,
CfFormField,
CfSwitch,
CfRadioGroup,
CfRadio,
CfSlider,
CfNumberInput,
CfDatePicker,
CfDropzone,
CfButton,
CfTag,
toast,
} from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
type PasswordPolicy = 'basic' | 'strict' | 'paranoid';
const initialForm = () => ({
twoFactor: true,
logRetention: 90,
sessionTimeout: 30,
passwordPolicy: 'strict' as PasswordPolicy,
nextBackup: '2026-05-13',
files: [] as File[],
});
export default function SystemSettings() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const [form, setForm] = useState(initialForm());
const [tab, setTab] = useState<'general' | 'security' | 'backup'>('general');
return (
<div className="adm-page">
<CfTabs modelValue={tab} onUpdateModelValue={(v) => setTab(v as typeof tab)} variant="line">
<CfTabPanel value="general" label={t.sys_general}>
<div className="adm-settings">
<CfForm model={form} layout="vertical">
<CfFormField label={t.sys_log_retention} name="logRetention">
<div className="adm-settings__row">
<CfSlider
modelValue={form.logRetention}
onUpdateModelValue={(v) => setForm({ ...form, logRetention: v })}
min={7} max={365} step={1} showValue style={{ flex: 1 }}
/>
<CfTag size="sm" tone="info">{form.logRetention} {ctx.locale === 'zh' ? '天' : 'days'}</CfTag>
</div>
<p className="adm-settings__hint">{t.sys_log_retention_hint}</p>
</CfFormField>
<CfFormField label={t.sys_session_timeout} name="sessionTimeout">
<CfNumberInput
modelValue={form.sessionTimeout}
onUpdateModelValue={(v) => setForm({ ...form, sessionTimeout: v })}
min={5} max={240} step={5} style={{ width: 160 }}
/>
</CfFormField>
</CfForm>
</div>
</CfTabPanel>
<CfTabPanel value="security" label={t.sys_security}>
<div className="adm-settings">
<CfForm model={form} layout="vertical">
<CfFormField label={t.sys_two_factor} name="twoFactor">
<div className="adm-settings__row">
<CfSwitch
modelValue={form.twoFactor}
onUpdateModelValue={(v) => setForm({ ...form, twoFactor: v })}
/>
<span className="adm-settings__hint adm-settings__hint--inline">{t.sys_two_factor_hint}</span>
</div>
</CfFormField>
<CfFormField label={t.sys_password_policy} name="passwordPolicy">
<CfRadioGroup
modelValue={form.passwordPolicy}
onUpdateModelValue={(v) => setForm({ ...form, passwordPolicy: v as PasswordPolicy })}
>
<CfRadio value="basic">{t.sys_password_policy_basic}</CfRadio>
<CfRadio value="strict">{t.sys_password_policy_strict}</CfRadio>
<CfRadio value="paranoid">{t.sys_password_policy_paranoid}</CfRadio>
</CfRadioGroup>
</CfFormField>
</CfForm>
</div>
</CfTabPanel>
<CfTabPanel value="backup" label={t.sys_backup}>
<div className="adm-settings">
<CfForm model={form} layout="vertical">
<CfFormField label={t.sys_next_backup} name="nextBackup">
<CfDatePicker
modelValue={form.nextBackup}
onUpdateModelValue={(v) => setForm({ ...form, nextBackup: String(v) })}
placeholder={t.sys_next_backup}
style={{ width: 220 }}
/>
</CfFormField>
<CfFormField label={t.sys_upload_logs} name="files">
<CfDropzone
modelValue={form.files}
onUpdateModelValue={(files) => setForm({ ...form, files })}
multiple
maxSize={20 * 1024 * 1024}
accept=".log,.txt,.gz,application/gzip,text/plain"
/>
</CfFormField>
</CfForm>
</div>
</CfTabPanel>
</CfTabs>
<footer className="adm-page__foot">
<CfButton variant="tertiary" onClick={() => { setForm(initialForm()); toast.info(t.sys_reverted); }}>
{t.sys_revert}
</CfButton>
<CfButton variant="primary" onClick={() => toast.success(t.sys_saved)}>
{t.sys_save_changes}
</CfButton>
</footer>
</div>
);
} import { useContext, useMemo, useState } from 'react';
import { CfTreeView, CfTable, CfTag, CfSplitter, CfBreadcrumb } from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import {
initialUsers,
initialDepartments,
collectDeptKeys,
findDepartment,
type AdminUser,
type Department,
} from '../mock';
function toTreeNodes(list: Department[]): { key: string; label: string; children?: ReturnType<typeof toTreeNodes> }[] {
return list.map((d) => ({
key: d.key,
label: d.label,
children: d.children ? toTreeNodes(d.children) : undefined,
}));
}
export default function Org() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const [selectedKey, setSelectedKey] = useState<string>('d-root');
const [expandedKeys, setExpandedKeys] = useState<string[]>(['d-root', 'd-rnd', 'd-ops']);
const treeNodes = useMemo(() => toTreeNodes(initialDepartments), []);
const visibleUsers = useMemo<AdminUser[]>(() => {
const dept = findDepartment(selectedKey);
if (!dept) return initialUsers;
const allowed = new Set(collectDeptKeys(dept));
return initialUsers.filter((u) => u.deptKey && allowed.has(u.deptKey));
}, [selectedKey]);
const breadcrumb = useMemo(() => {
const dept = findDepartment(selectedKey);
return dept ? [{ label: dept.label }] : [];
}, [selectedKey]);
const cols = [
{ key: 'username', title: t.col_username, dataIndex: 'username', width: 120 },
{ key: 'name', title: t.col_name, dataIndex: 'name', width: 140 },
{ key: 'email', title: t.col_email, dataIndex: 'email', ellipsis: true },
{
key: 'deptKey', title: t.col_dept, dataIndex: 'deptKey', width: 160,
render: (v: unknown) => {
const dept = v ? findDepartment(String(v)) : null;
return dept
? <CfTag size="sm" variant="soft" tone="primary">{dept.label}</CfTag>
: '—';
},
},
{
key: 'status', title: t.status, dataIndex: 'status', width: 100,
render: (v: unknown) =>
<CfTag size="sm" tone={v === 'active' ? 'success' : 'danger'} variant="soft">
{v === 'active' ? t.status_active : t.status_disabled}
</CfTag>,
},
];
return (
<div className="adm-page">
{breadcrumb.length ? <CfBreadcrumb items={breadcrumb} /> : null}
<p className="adm-page__hint">{t.org_select_hint}</p>
<CfSplitter orientation="horizontal" defaultSize={30} unit="%" className="adm-org">
<CfSplitter.Start>
<div className="adm-org__tree">
<CfTreeView
nodes={treeNodes}
selectable="single"
selectedKey={selectedKey}
onUpdateSelectedKey={(k) => setSelectedKey(k ?? 'd-root')}
expandedKeys={expandedKeys}
onUpdateExpandedKeys={setExpandedKeys}
showLine
size="sm"
/>
</div>
</CfSplitter.Start>
<CfSplitter.End>
<div className="adm-org__main">
<div className="adm-org__count">
{t.org_total.replace('{n}', String(visibleUsers.length))}
</div>
<CfTable
columns={cols}
rows={visibleUsers}
rowKey={(r: AdminUser) => String(r.id)}
size="sm"
stickyHeader
hoverable
/>
</div>
</CfSplitter.End>
</CfSplitter>
</div>
);
} import { useMemo, useState } from 'react';
import {
CfAppShell,
CfSidebar,
CfNavMenu,
CfBreadcrumb,
CfTag,
CfSearchInput,
CfIconButton,
CfPopover,
CfDropdown,
CfAvatar,
CfBadge,
CfDrawer,
CfSegmentedControl,
CfModal,
CfForm,
CfFormField,
CfInput,
CfPasswordStrength,
toast,
} from '@chufix-design/react';
import Dashboard from './pages/Dashboard';
import Users from './pages/Users';
import Roles from './pages/Roles';
import UserRoles from './pages/UserRoles';
import Dict from './pages/Dict';
import OperationLog from './pages/OperationLog';
import LoginLog from './pages/LoginLog';
import {
ACCENT_HUE,
DemoContext,
STRINGS,
} from './state';
export default function AdminMiniDemo() {
const [theme, setTheme] = useState<DemoTheme>('dark-cool');
const [density, setDensity] = useState<DemoDensity>('comfortable');
const [menuForm, setMenuForm] = useState<DemoMenuForm>('sidebar');
const [accent, setAccent] = useState<DemoAccent>('blue');
const [locale, setLocale] = useState<DemoLocale>('zh');
const [route, setRoute] = useState<RouteId>('dashboard');
const [settingsOpen, setSettingsOpen] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
const [passwordOpen, setPasswordOpen] = useState(false);
const t = STRINGS[locale];
const sidebarItems = useMemo(() => [
{
type: 'group',
label: t.grp_overview,
items: [{ key: 'dashboard', label: t.page_dashboard }],
},
{
type: 'group',
label: t.grp_authz,
items: [
{ key: 'users', label: t.page_users },
{ key: 'roles', label: t.page_roles },
{ key: 'user-roles', label: t.page_user_roles },
],
},
{
type: 'group',
label: t.grp_system,
items: [
{ key: 'dict', label: t.page_dict },
{ key: 'op-log', label: t.page_op_log },
{ key: 'login-log', label: t.page_login_log },
],
},
], [t]);
const pages= {
'dashboard': <Dashboard />,
'users': <Users />,
'roles': <Roles />,
'user-roles': <UserRoles />,
'dict': <Dict />,
'op-log': <OperationLog />,
'login-log': <LoginLog />,
};
const showSidebar = menuForm !== 'topbar';
const sidebarCollapsed = menuForm === 'collapsed';
function onLogout() {
toast.info(t.logout_done);
}
return (
<DemoContext.Provider
value={{ theme, setTheme, density, setDensity, menuForm, setMenuForm, accent, setAccent, locale, setLocale }}
>
<div
className="adm-root"
data-theme={theme}
data-density={density}
style={{ ['--accent-1': ACCENT_HUE[accent] }}
>
<CfAppShell
sidebarCollapsed={sidebarCollapsed}
sidebarWidth={sidebarCollapsed ? 64 : 220}
headerHeight={56}
header={
<AdminHeader
localeLabel={locale === 'zh' ? '中' : 'EN'}
onToggleLocale={() => setLocale((l) => (l === 'zh' ? 'en' : 'zh'))}
onOpenSettings={() => setSettingsOpen(true)}
onOpenProfile={() => setProfileOpen(true)}
onOpenChangePassword={() => setPasswordOpen(true)}
onLogout={onLogout}
t={t}
/>
}
sidebar={
showSidebar ? (
<CfSidebar
items={sidebarItems}
modelValue={route}
collapsed={sidebarCollapsed}
onUpdateModelValue={(key) => setRoute(key)}
/>
) : undefined
}
>
<section className="adm-body">
{menuForm === 'topbar' && (
<CfNavMenu
items={sidebarItems.flatMap((g) => g.items.map((i) => ({ key: i.key, label: i.label, href: '#' + i.key })))}
active={route}
variant="underline"
className="adm-body__nav"
onNavigate={(item) => setRoute(item.key)}
/>
)}
<CfBreadcrumb items={[{ label: t.brand }, { label: sidebarItems.flatMap((g) => g.items).find((i) => i.key === route)?.label ?? '' }]} />
<h2 className="adm-body__title">{sidebarItems.flatMap((g) => g.items).find((i) => i.key === route)?.label}</h2>
{pages[route]}
</section>
</CfAppShell>
<SettingsDrawer open={settingsOpen} onOpenChange={setSettingsOpen} />
<ProfileModal open={profileOpen} onOpenChange={setProfileOpen} />
<ChangePasswordModal open={passwordOpen} onOpenChange={setPasswordOpen} />
</div>
</DemoContext.Provider>
);
}
/* ============================================================ */
/* AdminHeader */
/* ============================================================ */
function AdminHeader({
localeLabel,
onToggleLocale,
onOpenSettings,
onOpenProfile,
onOpenChangePassword,
onLogout,
t,
}: AdminHeaderProps) {
const [search, setSearch] = useState('');
const [notifs, setNotifs] = useState([
{ id: 1, title: '用户 ada 提交了新角色申请', from: 'system', at: '2 分钟前', unread: true },
{ id: 2, title: '操作日志:grace 导出了登录日志', from: 'audit', at: '8 分钟前', unread: true },
{ id: 3, title: '字典「日志级别」新增 DEBUG 项', from: 'admin', at: '32 分钟前', unread: false },
{ id: 4, title: '今日凌晨完成数据库自动备份', from: 'cron', at: '昨天', unread: false },
]);
const unreadCount = notifs.filter((n) => n.unread).length;
const userMenuItems = [
{ key: 'user-info', label: 'Admin · [email protected]', header: true },
{ key: 'divider-1', divider: true },
{ key: 'profile', label: t.profile },
{ key: 'change-password', label: t.change_password },
{ key: 'divider-2', divider: true },
{ key: 'logout', label: t.logout, tone: 'danger' },
];
return (
<div className="adm-header">
<div className="adm-header__brand">
<span className="adm-header__logo" />
<span className="adm-header__title">{t.brand}</span>
<CfTag size="sm" tone="info" variant="soft">demo</CfTag>
</div>
<div className="adm-header__center">
<CfSearchInput
modelValue={search}
onUpdateModelValue={setSearch}
placeholder={t.search_placeholder}
size="sm"
/>
</div>
<div className="adm-header__actions">
<CfPopover placement="bottom" width={320}>
<button className="adm-iconbtn" type="button" aria-label={t.notifications}>
<CfBadge count={unreadCount} max={99} dot={false} showZero={false} placement="top-right">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 8a6 6 0 1112 0c0 6 3 7 3 7H3s3-1 3-7" />
<path d="M10 19a2 2 0 004 0" />
</svg>
</CfBadge>
</button>
<CfPopover.Content>
<div className="adm-notif">
<header className="adm-notif__head">
<span className="adm-notif__title">{t.notifications}</span>
<button
type="button"
className="adm-notif__link"
onClick={() => setNotifs((ns) => ns.map((n) => ({ ...n, unread: false })))}
>
{t.mark_all_read}
</button>
</header>
{notifs.length ? (
<ul className="adm-notif__list">
{notifs.map((n) => (
<li
key={n.id}
className={`adm-notif__item${n.unread ? ' is-unread' : ''}`}
>
<span className="adm-notif__dot" />
<div className="adm-notif__body">
<div className="adm-notif__text">{n.title}</div>
<div className="adm-notif__meta">{n.from} · {n.at}</div>
</div>
</li>
))}
</ul>
) : (
<div className="adm-notif__empty">{t.no_notifications}</div>
)}
</div>
</CfPopover.Content>
</CfPopover>
<button
className="adm-iconbtn adm-iconbtn--lang"
type="button"
aria-label={t.switch_locale}
onClick={onToggleLocale}
>
{localeLabel}
</button>
<button className="adm-iconbtn" type="button" aria-label={t.settings} onClick={onOpenSettings}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09a1.65 1.65 0 00-1-1.51 1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09a1.65 1.65 0 001.51-1 1.65 1.65 0 00-.33-1.82l-.06-.06A2 2 0 017.04 4.04l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9c.36.94 1.18 1.5 2.15 1.51H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
</button>
<CfDropdown
items={userMenuItems}
placement="bottom"
width={200}
onSelect={(item) => {
if (item.key === 'profile') onOpenProfile();
else if (item.key === 'change-password') onOpenChangePassword();
else if (item.key === 'logout') onLogout();
}}
>
<button className="adm-user" type="button">
<CfAvatar name="Admin" size="sm" />
<span className="adm-user__name">Admin</span>
<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4">
<path d="M4 6l4 4 4-4" />
</svg>
</button>
</CfDropdown>
</div>
</div>
);
}
/* ============================================================ */
/* SettingsDrawer */
/* ============================================================ */
function SettingsDrawer({ open, onOpenChange }: SettingsDrawerProps) {
// 简化引用:实际实现中读取 DemoContext。这里仅展示结构。
return (
<CfDrawer
open={open}
placement="right"
size="sm"
title="主题设置"
onUpdateOpen={onOpenChange}
>
<div className="adm-settings">
{/* theme / density / menu form / accent CfSegmentedControl + accent dots */}
</div>
</CfDrawer>
);
}
/* ============================================================ */
/* ProfileModal */
/* ============================================================ */
function ProfileModal({ open, onOpenChange }: ProfileModalProps) {
const [form, setForm] = useState({ name: '系统管理员', email: '[email protected]', phone: '13800000001' });
return (
<CfModal
open={open}
onUpdateOpen={onOpenChange}
title="个人中心"
size="md"
okText="保存"
cancelText="取消"
onBeforeOk={() => {
toast.success('资料已保存');
return true;
}}
>
<CfForm model={form} layout="vertical">
<CfFormField label="姓名" name="name">
<CfInput modelValue={form.name} onUpdateModelValue={(v) => setForm({ ...form, name: v })} />
</CfFormField>
<CfFormField label="邮箱" name="email">
<CfInput modelValue={form.email} onUpdateModelValue={(v) => setForm({ ...form, email: v })} />
</CfFormField>
<CfFormField label="手机号" name="phone">
<CfInput modelValue={form.phone} onUpdateModelValue={(v) => setForm({ ...form, phone: v })} />
</CfFormField>
</CfForm>
</CfModal>
);
}
/* ============================================================ */
/* ChangePasswordModal */
/* ============================================================ */
function ChangePasswordModal({ open, onOpenChange }: ChangePasswordModalProps) {
const [form, setForm] = useState({ current: '', next: '', confirm: '' });
return (
<CfModal
open={open}
onUpdateOpen={onOpenChange}
title="修改密码"
size="sm"
okText="保存"
cancelText="取消"
onBeforeOk={() => {
if (!form.current || !form.next || !form.confirm) {
toast.error('请填写完整');
return false;
}
if (form.next !== form.confirm) {
toast.error('两次输入的新密码不一致');
return false;
}
toast.success('密码已更新');
return true;
}}
>
<CfForm model={form} layout="vertical">
<CfFormField label="当前密码" name="current">
<CfInput modelValue={form.current} type="password" onUpdateModelValue={(v) => setForm({ ...form, current: v })} />
</CfFormField>
<CfFormField label="新密码" name="next">
<CfInput modelValue={form.next} type="password" onUpdateModelValue={(v) => setForm({ ...form, next: v })} />
{form.next ? <CfPasswordStrength value={form.next} size="sm" style={{ marginTop: 6 }} /> : null}
</CfFormField>
<CfFormField label="确认新密码" name="confirm">
<CfInput modelValue={form.confirm} type="password" onUpdateModelValue={(v) => setForm({ ...form, confirm: v })} />
</CfFormField>
</CfForm>
</CfModal>
);
} // admin-mini 演示的全局 UI 状态:主题 / 密度 / 菜单形态 / accent 色 / 语言。
// React 端用 Context 把状态下发到所有 page。
import { createContext } from 'react';
export const DemoContext = createContext<DemoState | null>(null);
export const ACCENT_HUE= {
blue: 'oklch(64% 0.16 263)',
green: 'oklch(68% 0.16 150)',
purple: 'oklch(64% 0.18 300)',
orange: 'oklch(72% 0.16 60)',
rose: 'oklch(66% 0.18 15)',
};
export const STRINGS= {
zh: {
brand: 'admin-mini 后台',
switch_theme: '主题', switch_density: '密度', switch_menu: '菜单形态',
switch_accent: '主色', switch_locale: '语言',
view_source: '查看源码', hide_source: '关闭源码',
page_dashboard: '工作台', page_users: '用户管理', page_roles: '角色管理',
page_user_roles: '用户角色', page_dict: '字典管理',
page_op_log: '操作日志', page_login_log: '登录日志',
grp_overview: '概览', grp_authz: '权限', grp_system: '系统',
search: '搜索', create: '新增', edit: '编辑', delete: '删除',
save: '保存', cancel: '取消',
status: '状态', status_active: '启用', status_disabled: '停用',
status_ok: '成功', status_fail: '失败',
status_success: '成功', status_failed: '失败',
actions: '操作', total_rows: '共 {n} 条',
col_id: 'ID', col_username: '账号', col_name: '姓名',
col_email: '邮箱', col_phone: '手机号', col_created_at: '创建时间',
col_role_name: '角色名', col_role_desc: '描述', col_role_perms: '权限',
col_user_roles: '已分配角色', col_assign: '分配角色',
col_dict_label: '名称', col_dict_value: '值', col_dict_remark: '备注',
col_log_user: '操作人', col_log_action: '动作', col_log_resource: '资源',
col_log_ip: 'IP', col_log_at: '时间', col_log_ua: '设备',
kpi_users: '用户总数', kpi_roles: '角色总数',
kpi_login_today: '今日登录', kpi_op_today: '今日操作',
recent_op: '最近操作', recent_login: '最近登录',
},
en: {
brand: 'admin-mini console',
switch_theme: 'Theme', switch_density: 'Density', switch_menu: 'Menu',
switch_accent: 'Accent', switch_locale: 'Locale',
view_source: 'View source', hide_source: 'Hide source',
page_dashboard: 'Dashboard', page_users: 'Users', page_roles: 'Roles',
page_user_roles: 'User roles', page_dict: 'Dictionary',
page_op_log: 'Operation log', page_login_log: 'Login log',
grp_overview: 'Overview', grp_authz: 'Authorization', grp_system: 'System',
search: 'Search', create: 'Create', edit: 'Edit', delete: 'Delete',
save: 'Save', cancel: 'Cancel',
status: 'Status', status_active: 'Active', status_disabled: 'Disabled',
status_ok: 'OK', status_fail: 'Failed',
status_success: 'Success', status_failed: 'Failed',
actions: 'Actions', total_rows: '{n} rows',
col_id: 'ID', col_username: 'Username', col_name: 'Name',
col_email: 'Email', col_phone: 'Phone', col_created_at: 'Created at',
col_role_name: 'Role', col_role_desc: 'Description', col_role_perms: 'Permissions',
col_user_roles: 'Assigned roles', col_assign: 'Assign',
col_dict_label: 'Label', col_dict_value: 'Value', col_dict_remark: 'Remark',
col_log_user: 'User', col_log_action: 'Action', col_log_resource: 'Resource',
col_log_ip: 'IP', col_log_at: 'Time', col_log_ua: 'Device',
kpi_users: 'Total users', kpi_roles: 'Total roles',
kpi_login_today: "Today's logins", kpi_op_today: "Today's operations",
recent_op: 'Recent operations', recent_login: 'Recent logins',
},
}; // admin-mini 演示用的内存 mock 数据,不接任何后端。
export const initialUsers= [
{ id: 1, username: 'admin', name: '系统管理员', email: '[email protected]', phone: '13800000001', status: 'active', createdAt: '2025-09-01 10:21' },
{ id: 2, username: 'ada', name: 'Ada Lovelace', email: '[email protected]', phone: '13800000002', status: 'active', createdAt: '2025-10-12 14:03' },
{ id: 3, username: 'linus', name: 'Linus Torvalds', email: '[email protected]', phone: '13800000003', status: 'active', createdAt: '2025-11-03 09:47' },
{ id: 4, username: 'grace', name: 'Grace Hopper', email: '[email protected]', phone: '13800000004', status: 'disabled', createdAt: '2025-11-19 16:55' },
{ id: 5, username: 'alan', name: 'Alan Turing', email: '[email protected]', phone: '13800000005', status: 'active', createdAt: '2026-01-08 11:32' },
{ id: 6, username: 'donald',name: 'Donald Knuth', email: '[email protected]',phone: '13800000006', status: 'active', createdAt: '2026-02-14 13:18' },
];
export const initialRoles= [
{ id: 'r-admin', name: '超级管理员', description: '拥有全部权限', permissions: ['user:*', 'role:*', 'dict:*', 'log:*'] },
{ id: 'r-manager', name: '业务管理员', description: '业务模块全部读写', permissions: ['user:read', 'user:write', 'role:read'] },
{ id: 'r-viewer', name: '只读账号', description: '只能查看,不能修改', permissions: ['user:read', 'role:read', 'log:read'] },
{ id: 'r-auditor', name: '审计员', description: '查看日志,不能修改业务', permissions: ['log:read', 'log:export'] },
];
export const initialUserRoles= [
{ userId: 1, roleIds: ['r-admin'] },
{ userId: 2, roleIds: ['r-manager'] },
{ userId: 3, roleIds: ['r-manager', 'r-auditor'] },
{ userId: 4, roleIds: ['r-viewer'] },
{ userId: 5, roleIds: ['r-viewer'] },
{ userId: 6, roleIds: ['r-auditor'] },
];
export const initialDict= [
{ id: 'd-status', parentId, label: '用户状态', value: 'user_status', remark: '系统内置' },
{ id: 'd-status-1', parentId: 'd-status', label: '启用', value: 'active', remark: '' },
{ id: 'd-status-2', parentId: 'd-status', label: '停用', value: 'disabled', remark: '' },
{ id: 'd-gender', parentId, label: '性别', value: 'gender', remark: '' },
{ id: 'd-gender-1', parentId: 'd-gender', label: '男', value: 'M', remark: '' },
{ id: 'd-gender-2', parentId: 'd-gender', label: '女', value: 'F', remark: '' },
{ id: 'd-gender-3', parentId: 'd-gender', label: '保密', value: 'X', remark: '' },
{ id: 'd-level', parentId, label: '日志级别', value: 'log_level', remark: '' },
{ id: 'd-level-1', parentId: 'd-level', label: 'INFO', value: 'info', remark: '' },
{ id: 'd-level-2', parentId: 'd-level', label: 'WARN', value: 'warn', remark: '' },
{ id: 'd-level-3', parentId: 'd-level', label: 'ERROR', value: 'error', remark: '' },
];
export const initialOpLogs= [
{ id: 1, user: 'admin', action: 'create', resource: '/api/users', ip: '10.1.0.21', at: '2026-05-12 08:21:03', status: 'ok' },
{ id: 2, user: 'ada', action: 'update', resource: '/api/roles/r-manager', ip: '10.1.0.22', at: '2026-05-12 08:19:51', status: 'ok' },
{ id: 3, user: 'linus', action: 'delete', resource: '/api/users/9', ip: '10.1.0.23', at: '2026-05-12 08:17:22', status: 'fail' },
{ id: 4, user: 'ada', action: 'login', resource: '/auth/login', ip: '10.1.0.22', at: '2026-05-12 08:14:08', status: 'ok' },
{ id: 5, user: 'grace', action: 'export', resource: '/api/logs/op', ip: '10.1.0.42', at: '2026-05-12 08:10:42', status: 'ok' },
{ id: 6, user: 'admin', action: 'update', resource: '/api/dict/d-status', ip: '10.1.0.21', at: '2026-05-12 08:02:13', status: 'ok' },
];
export const initialLoginLogs= [
{ id: 1, user: 'admin', ip: '10.1.0.21', ua: 'Chrome 124 / macOS', at: '2026-05-12 08:14:08', status: 'success' },
{ id: 2, user: 'ada', ip: '10.1.0.22', ua: 'Edge 123 / Windows', at: '2026-05-12 08:11:55', status: 'success' },
{ id: 3, user: 'linus', ip: '10.1.0.23', ua: 'Firefox 124 / Linux', at: '2026-05-12 08:09:31', status: 'success' },
{ id: 4, user: 'grace', ip: '203.0.113.6', ua: 'Safari 17 / iOS', at: '2026-05-12 08:07:18', status: 'failed' },
{ id: 5, user: 'grace', ip: '203.0.113.6', ua: 'Safari 17 / iOS', at: '2026-05-12 08:06:42', status: 'failed' },
{ id: 6, user: 'alan', ip: '10.1.0.24', ua: 'Chrome 124 / Linux', at: '2026-05-12 07:58:11', status: 'success' },
]; import { useContext } from 'react';
import { CfStat, CfTable, CfTag } from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import {
initialUsers,
initialRoles,
initialOpLogs,
initialLoginLogs,
} from '../mock';
export default function Dashboard() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const kpis = [
{ label: t.kpi_users, value: initialUsers.length, trend: { delta: '+2', direction: 'up' } },
{ label: t.kpi_roles, value: initialRoles.length, trend: { delta: '+1', direction: 'up' } },
{ label: t.kpi_login_today, value: 38, trend: { delta: '+12%', direction: 'up' } },
{ label: t.kpi_op_today, value: 142, trend: { delta: '-4%', direction: 'down' } },
];
const opCols = [
{ key: 'user', title: t.col_log_user, dataIndex: 'user', width: 100 },
{ key: 'action', title: t.col_log_action, dataIndex: 'action', width: 90 },
{ key: 'resource', title: t.col_log_resource, dataIndex: 'resource' },
{ key: 'at', title: t.col_log_at, dataIndex: 'at', width: 180 },
];
const loginCols = [
{ key: 'user', title: t.col_log_user, dataIndex: 'user', width: 100 },
{ key: 'ip', title: t.col_log_ip, dataIndex: 'ip', width: 130 },
{ key: 'ua', title: t.col_log_ua, dataIndex: 'ua' },
{ key: 'at', title: t.col_log_at, dataIndex: 'at', width: 180 },
];
return (
<div className="adm-dashboard">
<div className="adm-dashboard__kpis">
{kpis.map((k) => (
<CfStat key={k.label} label={k.label} value={k.value} trend={k.trend} variant="outlined" />
))}
</div>
<div className="adm-dashboard__pair">
<section className="adm-card">
<header className="adm-card__head">
<h3>{t.recent_op}</h3>
<CfTag size="sm" tone="info">live</CfTag>
</header>
<CfTable columns={opCols} rows={initialOpLogs.slice(0, 5)} rowKey={(r) => String(r.id)} size="sm" />
</section>
<section className="adm-card">
<header className="adm-card__head">
<h3>{t.recent_login}</h3>
<CfTag size="sm" tone="success">stable</CfTag>
</header>
<CfTable columns={loginCols} rows={initialLoginLogs.slice(0, 5)} rowKey={(r) => String(r.id)} size="sm" />
</section>
</div>
</div>
);
} import { useContext, useMemo, useState } from 'react';
import {
CfTable,
CfTag,
CfButton,
CfModal,
CfForm,
CfFormField,
CfInput,
CfSelect,
CfSearchInput,
} from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import { initialUsers } from '../mock';
export default function Users() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const [rows, setRows] = useState<AdminUser[]>(initialUsers);
const [search, setSearch] = useState('');
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<AdminUser | null>(null);
const [form, setForm] = useState<Draft>({ username: '', name: '', email: '', phone: '', status: 'active' });
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return rows;
return rows.filter((r) =>
[r.username, r.name, r.email, r.phone].some((v) => v.toLowerCase().includes(q)),
);
}, [rows, search]);
function openCreate() {
setEditing(null);
setForm({ username: '', name: '', email: '', phone: '', status: 'active' });
setOpen(true);
}
function openEdit(row) {
setEditing(row);
setForm({ username: row.username, name: row.name, email: row.email, phone: row.phone, status: row.status });
setOpen(true);
}
function remove(row) {
setRows((rs) => rs.filter((r) => r.id !== row.id));
}
function save() {
if (editing) {
const id = editing.id;
setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...form } : r)));
} else {
const nextId = (rows.reduce((m, r) => Math.max(m, r.id), 0) || 0) + 1;
setRows((rs) => [
...rs,
{ id: nextId, ...form, createdAt: new Date().toISOString().slice(0, 16).replace('T', ' ') },
]);
}
setOpen(false);
}
const cols = [
{ key: 'id', title: t.col_id, dataIndex: 'id', width: 60 },
{ key: 'username', title: t.col_username, dataIndex: 'username', width: 110 },
{ key: 'name', title: t.col_name, dataIndex: 'name', width: 140 },
{ key: 'email', title: t.col_email, dataIndex: 'email' },
{ key: 'phone', title: t.col_phone, dataIndex: 'phone', width: 130 },
{
key: 'status', title: t.status, dataIndex: 'status', width: 90,
render: (v) =>
<CfTag size="sm" tone={v === 'active' ? 'success' : 'danger'} variant="soft">
{v === 'active' ? t.status_active : t.status_disabled}
</CfTag>,
},
{ key: 'createdAt', title: t.col_created_at, dataIndex: 'createdAt', width: 160 },
{
key: 'actions', title: t.actions, dataIndex: 'id', width: 130, align: 'right',
render: (_v, row) =>
<div style={{ display: 'inline-flex', gap: 4, justifyContent: 'flex-end' }}>
<CfButton size="sm" variant="tertiary" onClick={() => openEdit(row)}>{t.edit}</CfButton>
<CfButton size="sm" variant="danger" onClick={() => remove(row)}>{t.delete}</CfButton>
</div>,
},
];
return (
<div className="adm-page">
<header className="adm-page__head">
<div className="adm-page__head-left">
<CfSearchInput modelValue={search} onUpdateModelValue={setSearch} placeholder={t.search} size="sm" style={{ width: 220 }} />
<span className="adm-page__count">{t.total_rows.replace('{n}', String(filtered.length))}</span>
</div>
<CfButton variant="primary" size="sm" onClick={openCreate}>+ {t.create}</CfButton>
</header>
<CfTable columns={cols} rows={filtered} rowKey={(r) => String(r.id)} size="sm" />
<CfModal
open={open}
onUpdateOpen={setOpen}
title={editing ? t.edit : t.create}
okText={t.save}
cancelText={t.cancel}
onBeforeOk={() => { save(); return true; }}
size="md"
>
<CfForm model={form} layout="vertical">
<CfFormField label={t.col_username} name="username">
<CfInput modelValue={form.username} onUpdateModelValue={(v) => setForm({ ...form, username: v })} />
</CfFormField>
<CfFormField label={t.col_name} name="name">
<CfInput modelValue={form.name} onUpdateModelValue={(v) => setForm({ ...form, name: v })} />
</CfFormField>
<CfFormField label={t.col_email} name="email">
<CfInput modelValue={form.email} type="email" onUpdateModelValue={(v) => setForm({ ...form, email: v })} />
</CfFormField>
<CfFormField label={t.col_phone} name="phone">
<CfInput modelValue={form.phone} onUpdateModelValue={(v) => setForm({ ...form, phone: v })} />
</CfFormField>
<CfFormField label={t.status} name="status">
<CfSelect
modelValue={form.status}
onUpdateModelValue={(v) => setForm({ ...form, status: v'status'] })}
options={[
{ value: 'active', label: t.status_active },
{ value: 'disabled', label: t.status_disabled },
]}
/>
</CfFormField>
</CfForm>
</CfModal>
</div>
);
} import { useContext } from 'react';
import { CfTable, CfTag } from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import { initialRoles } from '../mock';
export default function Roles() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const cols = [
{ key: 'name', title: t.col_role_name, dataIndex: 'name', width: 160 },
{ key: 'description', title: t.col_role_desc, dataIndex: 'description' },
{
key: 'permissions', title: t.col_role_perms, dataIndex: 'permissions',
render: (v) => (
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>
{(v).map((p) => (
<CfTag key={p} size="sm" variant="outline" tone="primary">{p}</CfTag>
))}
</div>
),
},
];
return (
<div className="adm-page">
<CfTable columns={cols} rows={initialRoles} rowKey={(r) => r.id} size="sm" />
</div>
);
} import { useContext, useState } from 'react';
import { CfTable, CfTag, CfButton, CfModal, CfCheckbox } from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import {
initialUsers,
initialRoles,
initialUserRoles,
} from '../mock';
export default function UserRoles() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const [links, setLinks] = useState<UserRoleLink[]>(
initialUserRoles.map((l) => ({ ...l, roleIds: [...l.roleIds] })),
);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<AdminUser | null>(null);
const [draft, setDraft] = useState<Set<string>>(new Set());
function rolesFor(userId): string[] {
return links.find((l) => l.userId === userId)?.roleIds ?? [];
}
function roleName(id): string {
return initialRoles.find((r) => r.id === id)?.name ?? id;
}
function openAssign(u) {
setEditing(u);
setDraft(new Set(rolesFor(u.id)));
setOpen(true);
}
function toggleRole(roleId) {
setDraft((prev) => {
const next = new Set(prev);
if (next.has(roleId)) next.delete(roleId);
else next.add(roleId);
return next;
});
}
function saveAssign() {
if (!editing) return;
const uid = editing.id;
const next = [...draft];
setLinks((ls) =>
ls.some((l) => l.userId === uid)
? ls.map((l) => (l.userId === uid ? { ...l, roleIds: next } : l))
: [...ls, { userId: uid, roleIds: next }],
);
setOpen(false);
}
const cols = [
{ key: 'username', title: t.col_username, dataIndex: 'username', width: 110 },
{ key: 'name', title: t.col_name, dataIndex: 'name', width: 140 },
{
key: 'roles', title: t.col_user_roles, dataIndex: 'id',
render: (v) => {
const ids = rolesFor(v);
if (!ids.length) return <span style={{ color: 'var(--fg-3)' }}>—</span>;
return (
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>
{ids.map((id) => (
<CfTag key={id} size="sm" variant="soft" tone="primary">{roleName(id)}</CfTag>
))}
</div>
);
},
},
{
key: 'assign', title: t.col_assign, dataIndex: 'id', width: 110, align: 'right',
render: (_v, row) =>
<CfButton size="sm" variant="tertiary" onClick={() => openAssign(row)}>{t.edit}</CfButton>,
},
];
return (
<div className="adm-page">
<CfTable columns={cols} rows={initialUsers} rowKey={(r) => String(r.id)} size="sm" />
<CfModal
open={open}
onUpdateOpen={setOpen}
title={editing ? `${editing.name} · ${t.col_assign}` : t.col_assign}
okText={t.save}
cancelText={t.cancel}
onBeforeOk={() => { saveAssign(); return true; }}
size="sm"
>
<div className="adm-assign">
{initialRoles.map((r) => (
<label key={r.id} className="adm-assign__row">
<CfCheckbox modelValue={draft.has(r.id)} onUpdateModelValue={() => toggleRole(r.id)} />
<span className="adm-assign__name">{r.name}</span>
<span className="adm-assign__desc">{r.description}</span>
</label>
))}
</div>
</CfModal>
</div>
);
} import { useContext, useMemo } from 'react';
import { CfTable } from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import { initialDict } from '../mock';
export default function Dict() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const tree = useMemo<DictNode[]>(() => {
const byParent = new Map<string | null, DictItem[]>();
for (const item of initialDict) {
const arr = byParent.get(item.parentId) ?? [];
arr.push(item);
byParent.set(item.parentId, arr);
}
const roots = byParent.get(null) ?? [];
return roots.map((r) => ({ ...r, children: byParent.get(r.id) ?? [] }));
}, []);
const cols = [
{ key: 'label', title: t.col_dict_label, dataIndex: 'label', width: 220 },
{ key: 'value', title: t.col_dict_value, dataIndex: 'value', width: 200 },
{ key: 'remark', title: t.col_dict_remark, dataIndex: 'remark' },
];
return (
<div className="adm-page">
<CfTable
columns={cols}
rows={tree}
rowKey={(r) => r.id}
defaultExpandedRowKeys={tree.map((n) => n.id)}
size="sm"
/>
</div>
);
} import { useContext, useMemo, useState } from 'react';
import { CfTable, CfTag, CfSearchInput } from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import { initialOpLogs} from '../mock';
export default function OperationLog() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return initialOpLogs;
return initialOpLogs.filter((r) =>
[r.user, r.action, r.resource, r.ip].some((v) => v.toLowerCase().includes(q)),
);
}, [search]);
const cols = [
{ key: 'user', title: t.col_log_user, dataIndex: 'user', width: 110 },
{ key: 'action', title: t.col_log_action, dataIndex: 'action', width: 100 },
{ key: 'resource', title: t.col_log_resource, dataIndex: 'resource' },
{ key: 'ip', title: t.col_log_ip, dataIndex: 'ip', width: 130 },
{
key: 'status', title: t.status, dataIndex: 'status', width: 90,
render: (v) =>
<CfTag size="sm" tone={v === 'ok' ? 'success' : 'danger'} variant="soft">
{v === 'ok' ? t.status_ok : t.status_fail}
</CfTag>,
},
{ key: 'at', title: t.col_log_at, dataIndex: 'at', width: 180 },
];
return (
<div className="adm-page">
<header className="adm-page__head">
<CfSearchInput modelValue={search} onUpdateModelValue={setSearch} placeholder={t.search} size="sm" style={{ width: 220 }} />
<span className="adm-page__count">{t.total_rows.replace('{n}', String(filtered.length))}</span>
</header>
<CfTable columns={cols} rows={filtered} rowKey={(r) => String(r.id)} size="sm" />
</div>
);
} import { useContext, useMemo, useState } from 'react';
import { CfTable, CfTag, CfSearchInput } from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import { initialLoginLogs} from '../mock';
export default function LoginLog() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return initialLoginLogs;
return initialLoginLogs.filter((r) =>
[r.user, r.ip, r.ua].some((v) => v.toLowerCase().includes(q)),
);
}, [search]);
const cols = [
{ key: 'user', title: t.col_log_user, dataIndex: 'user', width: 110 },
{ key: 'ip', title: t.col_log_ip, dataIndex: 'ip', width: 140 },
{ key: 'ua', title: t.col_log_ua, dataIndex: 'ua' },
{
key: 'status', title: t.status, dataIndex: 'status', width: 90,
render: (v) =>
<CfTag size="sm" tone={v === 'success' ? 'success' : 'danger'} variant="soft">
{v === 'success' ? t.status_success : t.status_failed}
</CfTag>,
},
{ key: 'at', title: t.col_log_at, dataIndex: 'at', width: 180 },
];
return (
<div className="adm-page">
<header className="adm-page__head">
<CfSearchInput modelValue={search} onUpdateModelValue={setSearch} placeholder={t.search} size="sm" style={{ width: 220 }} />
<span className="adm-page__count">{t.total_rows.replace('{n}', String(filtered.length))}</span>
</header>
<CfTable columns={cols} rows={filtered} rowKey={(r) => String(r.id)} size="sm" />
</div>
);
} import { useContext, useState } from 'react';
import {
CfTabs,
CfTabPanel,
CfForm,
CfFormField,
CfSwitch,
CfRadioGroup,
CfRadio,
CfSlider,
CfNumberInput,
CfDatePicker,
CfDropzone,
CfButton,
CfTag,
toast,
} from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
const initialForm = () => ({
twoFactor: true,
logRetention: 90,
sessionTimeout: 30,
passwordPolicy: 'strict'
nextBackup: '2026-05-13',
files: []
});
export default function SystemSettings() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const [form, setForm] = useState(initialForm());
const [tab, setTab] = useState<'general' | 'security' | 'backup'>('general');
return (
<div className="adm-page">
<CfTabs modelValue={tab} onUpdateModelValue={(v) => setTab(v)} variant="line">
<CfTabPanel value="general" label={t.sys_general}>
<div className="adm-settings">
<CfForm model={form} layout="vertical">
<CfFormField label={t.sys_log_retention} name="logRetention">
<div className="adm-settings__row">
<CfSlider
modelValue={form.logRetention}
onUpdateModelValue={(v) => setForm({ ...form, logRetention: v })}
min={7} max={365} step={1} showValue style={{ flex: 1 }}
/>
<CfTag size="sm" tone="info">{form.logRetention} {ctx.locale === 'zh' ? '天' : 'days'}</CfTag>
</div>
<p className="adm-settings__hint">{t.sys_log_retention_hint}</p>
</CfFormField>
<CfFormField label={t.sys_session_timeout} name="sessionTimeout">
<CfNumberInput
modelValue={form.sessionTimeout}
onUpdateModelValue={(v) => setForm({ ...form, sessionTimeout: v })}
min={5} max={240} step={5} style={{ width: 160 }}
/>
</CfFormField>
</CfForm>
</div>
</CfTabPanel>
<CfTabPanel value="security" label={t.sys_security}>
<div className="adm-settings">
<CfForm model={form} layout="vertical">
<CfFormField label={t.sys_two_factor} name="twoFactor">
<div className="adm-settings__row">
<CfSwitch
modelValue={form.twoFactor}
onUpdateModelValue={(v) => setForm({ ...form, twoFactor: v })}
/>
<span className="adm-settings__hint adm-settings__hint--inline">{t.sys_two_factor_hint}</span>
</div>
</CfFormField>
<CfFormField label={t.sys_password_policy} name="passwordPolicy">
<CfRadioGroup
modelValue={form.passwordPolicy}
onUpdateModelValue={(v) => setForm({ ...form, passwordPolicy: v})}
>
<CfRadio value="basic">{t.sys_password_policy_basic}</CfRadio>
<CfRadio value="strict">{t.sys_password_policy_strict}</CfRadio>
<CfRadio value="paranoid">{t.sys_password_policy_paranoid}</CfRadio>
</CfRadioGroup>
</CfFormField>
</CfForm>
</div>
</CfTabPanel>
<CfTabPanel value="backup" label={t.sys_backup}>
<div className="adm-settings">
<CfForm model={form} layout="vertical">
<CfFormField label={t.sys_next_backup} name="nextBackup">
<CfDatePicker
modelValue={form.nextBackup}
onUpdateModelValue={(v) => setForm({ ...form, nextBackup: String(v) })}
placeholder={t.sys_next_backup}
style={{ width: 220 }}
/>
</CfFormField>
<CfFormField label={t.sys_upload_logs} name="files">
<CfDropzone
modelValue={form.files}
onUpdateModelValue={(files) => setForm({ ...form, files })}
multiple
maxSize={20 * 1024 * 1024}
accept=".log,.txt,.gz,application/gzip,text/plain"
/>
</CfFormField>
</CfForm>
</div>
</CfTabPanel>
</CfTabs>
<footer className="adm-page__foot">
<CfButton variant="tertiary" onClick={() => { setForm(initialForm()); toast.info(t.sys_reverted); }}>
{t.sys_revert}
</CfButton>
<CfButton variant="primary" onClick={() => toast.success(t.sys_saved)}>
{t.sys_save_changes}
</CfButton>
</footer>
</div>
);
} import { useContext, useMemo, useState } from 'react';
import { CfTreeView, CfTable, CfTag, CfSplitter, CfBreadcrumb } from '@chufix-design/react';
import { DemoContext, STRINGS } from '../state';
import {
initialUsers,
initialDepartments,
collectDeptKeys,
findDepartment,
} from '../mock';
function toTreeNodes(list): { key: string; label: string; children?: ReturnType<typeof toTreeNodes> }[] {
return list.map((d) => ({
key: d.key,
label: d.label,
children: d.children ? toTreeNodes(d.children) : undefined,
}));
}
export default function Org() {
const ctx = useContext(DemoContext)!;
const t = STRINGS[ctx.locale];
const [selectedKey, setSelectedKey] = useState<string>('d-root');
const [expandedKeys, setExpandedKeys] = useState<string[]>(['d-root', 'd-rnd', 'd-ops']);
const treeNodes = useMemo(() => toTreeNodes(initialDepartments), []);
const visibleUsers = useMemo<AdminUser[]>(() => {
const dept = findDepartment(selectedKey);
if (!dept) return initialUsers;
const allowed = new Set(collectDeptKeys(dept));
return initialUsers.filter((u) => u.deptKey && allowed.has(u.deptKey));
}, [selectedKey]);
const breadcrumb = useMemo(() => {
const dept = findDepartment(selectedKey);
return dept ? [{ label: dept.label }] : [];
}, [selectedKey]);
const cols = [
{ key: 'username', title: t.col_username, dataIndex: 'username', width: 120 },
{ key: 'name', title: t.col_name, dataIndex: 'name', width: 140 },
{ key: 'email', title: t.col_email, dataIndex: 'email', ellipsis: true },
{
key: 'deptKey', title: t.col_dept, dataIndex: 'deptKey', width: 160,
render: (v) => {
const dept = v ? findDepartment(String(v)) : null;
return dept
? <CfTag size="sm" variant="soft" tone="primary">{dept.label}</CfTag>
: '—';
},
},
{
key: 'status', title: t.status, dataIndex: 'status', width: 100,
render: (v) =>
<CfTag size="sm" tone={v === 'active' ? 'success' : 'danger'} variant="soft">
{v === 'active' ? t.status_active : t.status_disabled}
</CfTag>,
},
];
return (
<div className="adm-page">
{breadcrumb.length ? <CfBreadcrumb items={breadcrumb} /> : null}
<p className="adm-page__hint">{t.org_select_hint}</p>
<CfSplitter orientation="horizontal" defaultSize={30} unit="%" className="adm-org">
<CfSplitter.Start>
<div className="adm-org__tree">
<CfTreeView
nodes={treeNodes}
selectable="single"
selectedKey={selectedKey}
onUpdateSelectedKey={(k) => setSelectedKey(k ?? 'd-root')}
expandedKeys={expandedKeys}
onUpdateExpandedKeys={setExpandedKeys}
showLine
size="sm"
/>
</div>
</CfSplitter.Start>
<CfSplitter.End>
<div className="adm-org__main">
<div className="adm-org__count">
{t.org_total.replace('{n}', String(visibleUsers.length))}
</div>
<CfTable
columns={cols}
rows={visibleUsers}
rowKey={(r) => String(r.id)}
size="sm"
stickyHeader
hoverable
/>
</div>
</CfSplitter.End>
</CfSplitter>
</div>
);
}