← 所有工程方案 Admin 后台管理

admin-mini · 后台管理

完整可交互的后台管理工程:顶部 header(搜索 / 消息铃铛 / 语言 / 主题齿轮 / 用户头像下拉)+ 左侧菜单 / 顶栏 / 折叠侧栏三种菜单形态、三套主题、两种密度、五种主色、中英文双语;用户 / 角色 / 用户角色 / 字典 / 操作日志 / 登录日志 7 个真实页面,可直接在演示里点开个人中心 / 修改密码 / CRUD 弹窗 / 权限分配 / 搜索等交互。

VueReact 18 文件
admin-mini · source
src/AdminMiniDemo.vue vue
<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>
src/AdminHeader.vue vue
<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>
src/Login.vue vue
<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>
src/SettingsDrawer.vue vue
<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>
src/ProfileModal.vue vue
<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>
src/ChangePasswordModal.vue vue
<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>
src/state.ts ts
// 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',
  },
};
src/mock.ts ts
// 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' },
];
src/pages/Dashboard.vue vue
<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>
src/pages/Users.vue vue
<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>
src/pages/Roles.vue vue
<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>
src/pages/UserRoles.vue vue
<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>
src/pages/Dict.vue vue
<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>
src/pages/OperationLog.vue vue
<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>
src/pages/LoginLog.vue vue
<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>
src/pages/SystemSettings.vue vue
<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>
src/pages/Org.vue vue
<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>
src/pages/Menus.vue vue
<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>
src/AdminMiniDemo.vue vue
<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>
src/AdminHeader.vue vue
<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>
src/Login.vue vue
<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>
src/SettingsDrawer.vue vue
<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>
src/ProfileModal.vue vue
<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>
src/ChangePasswordModal.vue vue
<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>
src/state.js js
// 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',
  },
};
src/mock.js js
// 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' },
];
src/pages/Dashboard.vue vue
<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>
src/pages/Users.vue vue
<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>
src/pages/Roles.vue vue
<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>
src/pages/UserRoles.vue vue
<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>
src/pages/Dict.vue vue
<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>
src/pages/OperationLog.vue vue
<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>
src/pages/LoginLog.vue vue
<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>
src/pages/SystemSettings.vue vue
<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>
src/pages/Org.vue vue
<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>
src/pages/Menus.vue vue
<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>
src/AdminMiniDemo.tsx tsx
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>
  );
}
src/state.ts ts
// 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',
  },
};
src/mock.ts ts
// 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' },
];
src/pages/Dashboard.tsx tsx
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>
  );
}
src/pages/Users.tsx tsx
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>
  );
}
src/pages/Roles.tsx tsx
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>
  );
}
src/pages/UserRoles.tsx tsx
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>
  );
}
src/pages/Dict.tsx tsx
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>
  );
}
src/pages/OperationLog.tsx tsx
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>
  );
}
src/pages/LoginLog.tsx tsx
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>
  );
}
src/pages/SystemSettings.tsx tsx
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>
  );
}
src/pages/Org.tsx tsx
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>
  );
}
src/AdminMiniDemo.jsx jsx
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>
  );
}
src/state.js js
// 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',
  },
};
src/mock.js js
// 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' },
];
src/pages/Dashboard.jsx jsx
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>
  );
}
src/pages/Users.jsx jsx
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>
  );
}
src/pages/Roles.jsx jsx
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>
  );
}
src/pages/UserRoles.jsx jsx
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>
  );
}
src/pages/Dict.jsx jsx
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>
  );
}
src/pages/OperationLog.jsx jsx
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>
  );
}
src/pages/LoginLog.jsx jsx
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>
  );
}
src/pages/SystemSettings.jsx jsx
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>
  );
}
src/pages/Org.jsx jsx
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>
  );
}