Commit 0f9e79a0 authored by Andy-bubu's avatar Andy-bubu

fix: align application switcher with Xinyuan baseline

parent 14cd2363
......@@ -20,6 +20,7 @@ app.js (主题初始化)
- `src/config/application.js`:应用名、base、主题存储键。
- `src/design-system/`:权威主题、Tokens 和跨应用 Shell 样式;页面不得复制这些规则。
- `AppShell/DashboardSidebar/DashboardNavbar/ApplicationSwitcherDialog`:业务无关的共享壳层。
- `ApplicationSwitcherDialog` 从既有 `SiteContext.site.group` 读取动态分组,从 `GET /application/info` 与 `UserContext.applications` 合并真实可访问应用;分组仅影响呈现,不改变路由、会话或跳转。
- `SubjectWorkbench`:把现有 Subject/View/动作配置适配为工作台 props,不定义组件外观。
- `record-workbench`:DataGrid/Pagination/ActionBar 组合;详情能力统一映射为操作列首个预览图标,首个业务列仅展示文本;不发请求、不解释业务动作。
- `RecordDetailDrawer`:详情草稿、Modal 状态和字段编排;保存仍由 `useSubjectRecord` 处理。
......
......@@ -13,7 +13,7 @@
- [x] 登录、鉴权、真实退出
- [x] Pro Sidebar/Navbar/offcanvas/mobile
- [x] HeroUI 应用切换 Modal:46rem/42rem、桌面两列/移动单列、12px 列间距、API Logo/首字 identity、唯一当前态;用户 Dropdown
- [x] HeroUI 应用切换 Modal:model_analysis 基准、46rem/42rem、28px inset、动态分组与真实计数、桌面两列/移动单列、API Logo/首字 identity、唯一当前态;用户 Dropdown
- [x] 七条 SubjectWorkbench 路由
- [x] Tabs、SearchField/Surface 筛选
- [x] Pro DataGrid 选择、排序、固定列、横向滚动
......
......@@ -17,7 +17,7 @@ Inter 禁止通过 `src/tailwind.css` 导入。Tailwind v4 内容扫描必须排
- Shell:`@heroui-pro/react/sidebar`、`navbar`;桌面 offcanvas 真折叠,移动端 `Sidebar.Mobile`。
- 全局操作:HeroUI Button/Tooltip/Dropdown;用户菜单的退出项调用真实登出回调。
- 应用切换:HeroUI Modal + Card/Surface/Avatar/Chip;最大 46rem x 42rem,桌面两列、移动端单列,列间距 12px。应用项使用 HeroUI Card 默认 24px 圆角与 16px 内边距;API 有 Logo 时显示真实 Logo,无 Logo 时显示应用首字,不得给所有应用重复套用鑫元 Logo。
- 应用切换:严格使用 model_analysis 当前 HeroUI Modal + Card/Surface/Avatar/Chip 基准;最大 46rem x 42rem、28px inset、24px 圆角,桌面两列、移动端单列,间距 12px。分组来自 `site.group`,保持后端分组与组内顺序并显示真实数量;未分组应用进入“其他应用”,无配置时回退“全部应用”。API 有 Logo 时显示真实 Logo,无 Logo 时显示应用首字,当前应用只显示一个 accent Chip。
- 数据视图:HeroUI Tabs;不手写 segmented/tab 外观。
- 筛选:HeroUI Surface + SearchField/Autocomplete/DatePicker/Disclosure。没有业务配置的筛选不得添加。
- 列表:HeroUI Pro DataGrid、EmptyState、ActionBar + HeroUI Pagination;分页固定沿用业务默认每页 10 条,不显示额外页容量选择器。首列只在真实横向溢出时固定且仅展示文本,详情入口统一为操作列首个预览图标。操作列宽度由 32px 动作按钮、8px 间距和标准单元格内边距计算,`minWidth/width/maxWidth` 必须一致;表格未横向溢出时首个业务列使用 `1fr` 吸收剩余宽度,操作列不得参与余量分配;仅在横向溢出时固定在右端。
......
......@@ -45,7 +45,7 @@
- 登录:已使用基准居中 Card、Logo、字段高度、24px 节奏、验证码、filled/focus/autofill 状态;保留原登录协议。
- Shell:HeroUI Pro `Sidebar.Provider collapsible="offcanvas"`、Sidebar、Mobile、Trigger、Navbar 与真实用户 Dropdown/登出。
- 应用切换:HeroUI Modal,46rem/42rem 合同,桌面两列、移动端单列,当前应用状态与 identity well。
- 应用切换:以 model_analysis 当前实现为唯一基准,HeroUI Modal 46rem/42rem、28px inset、桌面两列/移动端单列;复用 `SiteContext.site.group` 动态分组,显示真实计数,并保留唯一当前态与 identity well。
- Tabs/筛选:HeroUI Tabs、SearchField、Surface;当前业务仅配置标题关键词,不添加虚构筛选字段。
- 列表:Pro DataGrid、原生多选/排序/列宽/固定列/横向滚动、HeroUI Pagination、Pro ActionBar/EmptyState;分页固定每页 10 条并按问题整改基准移除页容量选择器;4 个以上动作只直出前两个,其余进入 Dropdown,危险动作最后。
- 弹窗:HeroUI Modal/AlertDialog;详情查看态单一 secondary Surface;指派保留远程用户查询和 ID 提交。
......@@ -69,7 +69,9 @@
操作列宽度回归复测:与运行中的“问题整改”在 1440x900 同视口比较,基准操作列为 144px。当前主表恢复同一列宽分配规则:未横向溢出时由首个业务列 `1fr` 吸收剩余宽度,操作列保持 144px;实测操作列 `x=1244..1388`,内容区 `x=1260..1372`,左右内边距均为 16px,三个 32px 按钮间距均为 8px。1440x900、1280x720、768x1024、390x844 浅/深色均复测,操作列在每个视口都为 144px;窄视口横向溢出态固定在 DataGrid 右端,四个视口 `document.body.scrollWidth === innerWidth`。
应用切换专项复测:对照“问题整改”的 46rem/42rem Modal、Header、24px 关闭按钮和 36px identity 几何,使用桌面两列、移动端单列。1440x900 下两列应用项实测为 338x72px,HeroUI Card 使用默认 24px 圆角和 16px 内边距,网格间距 12px;移除无业务 Logo 时统一显示鑫元 Logo 的错误兜底,恢复 API Logo / 应用首字规则;只显示一个“当前”。390x844 下应用项为 310x72px,正文独立滚动,`document.body.scrollWidth === innerWidth === 390`;浅深色均已验证。
应用切换专项基准更新:应用切换改以 model_analysis 当前实现为唯一基准。结构使用 HeroUI Modal compound API,分组直接读取既有 `GET /info -> site.group`,应用仍由 `GET /application/info` 与登录用户可访问应用合并;保持后端分组及组内顺序、过滤空组和无权限项、显示真实数量,未引用项进入“其他应用”,无分组配置时回退“全部应用”。固定 46rem/42rem、28px inset、24px Modal 圆角、24px/12px 分组节奏、sm 起两列、移动一列、36px identity well,并确保只有一个“当前” Chip。
应用切换浏览器验收:同时打开目标 `http://localhost:8002/integrity_practice_management/` 与基准 `http://localhost:8003/model_analysis/`,在 1440x900、1280x720、768x1024、390x844 的浅色和深色模式逐项比较。1440x900 实测两侧均为 736x672px、24px 圆角、28px inset、Header 56px、Header/Body 间距 12px、关闭按钮 24x24px 且距右上各 28px、两列各 334px且间距 12px;1280x720 为 736x640px,768x1024 仍为两列,390x844 为 358x672px、左右各 16px 且单列 302px。各视口 Body 均独立滚动,页面横向滚动宽度等于视口宽度。Esc、关闭按钮和遮罩均可关闭,焦点回到“切换应用”入口;打开后 Tab 顺序从 Dialog 到关闭按钮。无 `site.group` 时验证只回退一个“全部应用”分组;`GET /application/info` 失败时验证 warning 与登录用户应用回退;透明 Logo 保持原图,损坏或缺失 Logo 回退首字,当前应用使用鑫元 Logo,四类 identity well 均为 36x36px;全部路径都保持唯一当前态。全部稳定帧截图存放在 `/tmp/integrity-switcher-qa.*`,未写入源码或 Git。
操作图标三层几何专项复测:清除 HeroUI `.button svg` 默认的 `4px -2px` margin,并把表格操作按钮在桌面和移动端都锁定为 32x32 正圆。`button`、Ant `.anticon` 和内部 `svg` 逐层实测为 32x32 / 16x16 / 16x16,内层 SVG margin 为 0,水平及垂直圆心偏差均为 0px。
......@@ -81,7 +83,7 @@
退出登录使用独立 `logout-proof` 会话复测:点击 Sidebar 用户菜单的“退出登录”后,`POST /api/auth/logout` 返回 200,页面跳转到 `/integrity_practice_management/login`,`current_user` 与 `auth_token` 同时从 localStorage 删除,仅保留非鉴权主题键;控制台 0 error。此前旧验收会话中登录页仍带 `current_user` 是会话夹具残留,不是产品退出流程的持久化行为。
新页面重载后控制台:0 error;保留 1 条开发环境 warning。曾发现并修复 `ComboBox.InputGroup fullWidth` DOM 透传错误,以及 checkbox 行点击误开详情的交互冲突。
新页面重载后控制台:0 error;保留 1 种既有 `PressResponder` 开发环境 warning,在 React StrictMode 下打印两次,应用切换打开/关闭没有新增日志。曾发现并修复 `ComboBox.InputGroup fullWidth` DOM 透传错误,以及 checkbox 行点击误开详情的交互冲突。
## 未挂载历史边界
......
import xinyuanLogo from '@/assets/xy-logo-red.png';
import { ExclamationTriangleIcon } from '@/components/AppIcons';
import { APPLICATION_DISPLAY_NAME, APPLICATION_ROUTE_NAME } from '@/config/application';
import { getApplications } from '@/services/DataService';
import { Close, ExclamationTriangleIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Avatar, Card, Chip, Description, Modal, Skeleton, Surface } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
function unwrapApplications(response) {
for (const candidate of [response, response?.data, response?.applications, response?.apps, response?.records, response?.rows, response?.list, response?.result]) {
if (Array.isArray(candidate)) return candidate;
}
return [];
}
function normalizeApplication(application = {}) {
const name = application.name || '';
const current = name === APPLICATION_ROUTE_NAME;
return {
name,
title: application?.title || fallback?.title || name || '未命名应用',
description: application?.description || fallback?.description || name || '应用',
logo: application?.logo || fallback?.logo || null,
url: application?.url || (name ? `/${name}` : '#'),
title: current ? APPLICATION_DISPLAY_NAME : application.title || name || '未命名应用',
description: application.description || application.subtitle || application.title || '进入对应应用继续处理业务',
logo: current ? xinyuanLogo : application.logo || application.icon || null,
order: Number(application.order || 0),
url: application.url || application.path || (name ? `/${name}` : '#'),
};
}
function mergeApplications(fetched, fallback) {
const fallbackMap = new Map(fallback.map((item) => [item.name, item]));
function mergeApplications(fetched, fallbacks) {
const merged = new Map();
fetched.forEach((item) => {
const normalized = normalizeApplication(item, fallbackMap.get(item?.name));
if (normalized.name) merged.set(normalized.name, normalized);
});
fallback.forEach((item) => {
if (item.name && !merged.has(item.name)) merged.set(item.name, item);
});
return Array.from(merged.values());
[...fallbacks, ...fetched, { name: APPLICATION_ROUTE_NAME, title: APPLICATION_DISPLAY_NAME }]
.map(normalizeApplication)
.filter((application) => application.name)
.forEach((application) => merged.set(application.name, { ...merged.get(application.name), ...application }));
return [...merged.values()].sort((left, right) => left.order - right.order || left.title.localeCompare(right.title, 'zh-CN'));
}
function buildApplicationSections(applications, applicationGroups) {
if (!Array.isArray(applicationGroups) || applicationGroups.length === 0) {
return [{ key: 'all', label: '全部应用', applications }];
}
const applicationMap = new Map(applications.map((application) => [application.name, application]));
const groupedApplicationNames = new Set();
const sections = applicationGroups
.map((group, index) => {
const groupApplications = Array.isArray(group?.applications)
? group.applications
.map((name) => {
const application = applicationMap.get(name);
if (application) groupedApplicationNames.add(name);
return application;
})
.filter(Boolean)
: [];
return {
key: `group-${index}-${group?.label || 'applications'}`,
label: group?.label || '应用分组',
applications: groupApplications,
};
})
.filter((section) => section.applications.length > 0);
const ungroupedApplications = applications.filter(
(application) => !groupedApplicationNames.has(application.name),
);
if (ungroupedApplications.length > 0) {
sections.push({
key: 'ungrouped',
label: '其他应用',
applications: ungroupedApplications,
});
}
return sections;
}
function isCurrentApplication(application) {
if (application.name === APPLICATION_ROUTE_NAME) return true;
if (typeof window === 'undefined') return false;
const root = application?.name ? `/${application.name}` : '';
return Boolean(root && (window.location.pathname === root || window.location.pathname.startsWith(`${root}/`)));
try {
const target = new URL(application.url, window.location.origin);
const root = application.name ? `/${application.name}` : target.pathname;
return window.location.pathname === root || window.location.pathname.startsWith(`${root}/`);
} catch {
return false;
}
}
function ApplicationCard({ application }) {
const current = isCurrentApplication(application);
const external = application.url?.startsWith('http');
function ApplicationCard({ application, current }) {
const target = /^https?:\/\//i.test(application.url) ? '_blank' : undefined;
const initial = application.title.trim().slice(0, 1).toUpperCase() || 'A';
return (
<Card className="h-full" variant="secondary">
......@@ -45,18 +98,18 @@ function ApplicationCard({ application }) {
aria-current={current ? 'page' : undefined}
className="flex min-w-0 items-center gap-3 no-underline"
href={application.url}
rel={external ? 'noreferrer' : undefined}
target={external ? '_blank' : undefined}
rel={target ? 'noreferrer' : undefined}
target={target}
>
<Surface aria-hidden="true" className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--rc-radius-control)]" variant="tertiary">
<Avatar className="size-full rounded-[var(--rc-radius-control)] bg-transparent" color="accent" variant="soft">
{application.logo ? <Avatar.Image className="object-contain p-1.5" src={application.logo} alt="" /> : null}
<Surface aria-hidden="true" className="rc-app-identity-well grid size-9 shrink-0 place-items-center overflow-hidden" variant="tertiary">
<Avatar className="rc-app-identity-avatar size-full" color="accent" variant="soft">
{application.logo ? <Avatar.Image alt="" className="object-contain p-1.5" src={application.logo} /> : null}
<Avatar.Fallback>{initial}</Avatar.Fallback>
</Avatar>
</Surface>
<span className="min-w-0 flex-1">
<span className="block break-words font-medium leading-5 text-foreground" title={application.title}>{application.title}</span>
<span className="block truncate text-sm text-muted" title={application.description}>{application.description || application.name}</span>
<span className="block truncate text-sm font-medium leading-5" title={application.title}>{application.title}</span>
<Description className="mt-0.5 block truncate leading-4" title={application.description}>{application.description}</Description>
</span>
{current ? <Chip color="accent" size="sm">当前</Chip> : null}
</a>
......@@ -65,62 +118,104 @@ function ApplicationCard({ application }) {
);
}
export default function ApplicationSwitcherDialog({ open, onClose, userInfo }) {
const fallback = useMemo(
() => (userInfo?.applications || [])
.map((item) => normalizeApplication(item, { ...item, description: item?.title }))
.filter((item) => item.name),
function LoadingSkeleton() {
return (
<div aria-label="正在加载应用" className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 6 }).map((_, index) => (
<Card key={index} variant="secondary">
<Card.Content className="flex items-center gap-3">
<Skeleton className="size-9" />
<div className="flex-1 space-y-2"><Skeleton className="h-4 w-2/3" /><Skeleton className="h-3 w-full" /></div>
</Card.Content>
</Card>
))}
</div>
);
}
export default function ApplicationSwitcherDialog({ open, onClose, siteInfo, userInfo }) {
const fallbacks = useMemo(
() => (Array.isArray(userInfo?.applications) ? userInfo.applications : []),
[userInfo],
);
const [applications, setApplications] = useState(fallback);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [applications, setApplications] = useState(() => mergeApplications([], fallbacks));
const [status, setStatus] = useState('idle');
const [errorMessage, setErrorMessage] = useState('');
const currentApplicationName = useMemo(
() => applications.find(isCurrentApplication)?.name || APPLICATION_ROUTE_NAME,
[applications],
);
const applicationSections = useMemo(
() => buildApplicationSections(applications, siteInfo?.group),
[applications, siteInfo?.group],
);
const currentApplicationKey = useMemo(() => {
for (const section of applicationSections) {
const applicationIndex = section.applications.findIndex(
(application) => application.name === currentApplicationName,
);
if (applicationIndex >= 0) return `${section.key}:${applicationIndex}:${currentApplicationName}`;
}
return '';
}, [applicationSections, currentApplicationName]);
useEffect(() => {
if (!open) return undefined;
let cancelled = false;
setLoading(true);
setError('');
setStatus((previous) => (previous === 'success' ? previous : 'loading'));
setErrorMessage('');
getApplications()
.then((response) => {
if (!cancelled) setApplications(mergeApplications(Array.isArray(response) ? response : [], fallback));
if (cancelled) return;
setApplications(mergeApplications(unwrapApplications(response), fallbacks));
setStatus('success');
})
.catch(() => {
if (!cancelled) {
setApplications(fallback);
setError('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
if (cancelled) return;
setApplications(mergeApplications([], fallbacks));
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
});
return () => {
cancelled = true;
};
}, [fallback, open]);
return () => { cancelled = true; };
}, [fallbacks, open]);
return (
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop isOpen={open} variant="blur" onOpenChange={(isOpen) => !isOpen && onClose?.()}>
<Modal.Container className="w-full sm:w-full" placement="center" scroll="inside" size="lg">
<Modal.Dialog
className="max-h-[min(42rem,calc(100dvh-2rem))]"
style={{ maxWidth: 'min(46rem, calc(100vw - 2rem))' }}
>
<Modal.CloseTrigger aria-label="关闭"><Close size={16} /></Modal.CloseTrigger>
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog aria-label="应用切换" className="rc-app-switcher-dialog p-7">
<Modal.CloseTrigger
aria-label="关闭"
style={{ insetInlineEnd: '1.75rem', top: '1.75rem' }}
/>
<Modal.Header>
<div className="min-w-0"><Modal.Heading>应用切换</Modal.Heading><Description>选择你可访问的应用</Description></div>
<Modal.Heading>应用切换</Modal.Heading>
<Description className="leading-5">选择你可访问的应用</Description>
</Modal.Header>
<Modal.Body>
{error ? <Alert status="warning"><Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}
{loading && applications.length === 0 ? (
<div aria-label="正在加载应用" className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 6 }).map((_, index) => <Card className="h-full" key={index} variant="secondary"><Card.Content className="flex items-center gap-3"><Skeleton className="size-9" /><div className="flex-1 space-y-2"><Skeleton className="h-4 w-2/3" /><Skeleton className="h-3 w-full" /></div></Card.Content></Card>)}
</div>
) : applications.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{applications.map((application) => <ApplicationCard application={application} key={application.name} />)}
<Modal.Body style={{ marginTop: '0.75rem' }}>
{status === 'error' && errorMessage ? <Alert status="warning"><Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator><Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content></Alert> : null}
{status === 'loading' && applications.length === 0 ? <LoadingSkeleton /> : applications.length > 0 ? (
<div className="space-y-6">
{applicationSections.map((section) => (
<section key={section.key}>
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-medium leading-5">{section.label}</h3>
<Description>{section.applications.length} 个应用</Description>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{section.applications.map((application, applicationIndex) => {
const applicationKey = `${section.key}:${applicationIndex}:${application.name}`;
return (
<ApplicationCard
application={application}
current={applicationKey === currentApplicationKey}
key={applicationKey}
/>
);
})}
</div>
</section>
))}
</div>
) : (
<EmptyState size="md"><EmptyState.Header><EmptyState.Title>暂无可切换应用</EmptyState.Title><EmptyState.Description>当前账号还没有分配其它应用权限。</EmptyState.Description></EmptyState.Header></EmptyState>
......
export const APPLICATION_DISPLAY_NAME = '廉洁从业管理';
export const APPLICATION_ROUTE_NAME = 'integrity_practice_management';
export const APPLICATION_BASE_PATH = '/integrity_practice_management/';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
......
......@@ -539,6 +539,21 @@
margin-top: 1.5rem;
}
.rc-app-switcher-dialog {
width: min(46rem, calc(100vw - 2rem));
max-width: min(46rem, calc(100vw - 2rem));
max-height: min(42rem, calc(100dvh - 2rem));
}
.rc-app-identity-well,
.rc-app-identity-avatar {
border-radius: var(--rc-radius-control);
}
.rc-app-identity-avatar {
background: transparent;
}
.rc-detail-surface.surface--secondary {
border-radius: var(--rc-radius-detail-surface);
}
......
......@@ -3,6 +3,7 @@ import { Archive, CheckSquare, FileDone, FileText, Settings } from '@/components
import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog';
import { APPLICATION_DISPLAY_NAME, ROUTE_TITLES } from '@/config/application';
import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import { useContext, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation } from 'umi';
import { INTEGRITY_PRACTICE_MENU } from './integrityPracticeConfig';
......@@ -21,6 +22,7 @@ const MENU_GROUPS = INTEGRITY_PRACTICE_MENU.map((group, index) => ({
export default function IntegrityPracticeLayout() {
const location = useLocation();
const userInfo = useContext(UserContext);
const siteInfo = useContext(SiteContext);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [switcherOpen, setSwitcherOpen] = useState(false);
const pageTitle = useMemo(
......@@ -46,7 +48,12 @@ export default function IntegrityPracticeLayout() {
>
<Outlet />
</AppShell>
<ApplicationSwitcherDialog open={switcherOpen} onClose={() => setSwitcherOpen(false)} userInfo={userInfo} />
<ApplicationSwitcherDialog
open={switcherOpen}
siteInfo={siteInfo}
userInfo={userInfo}
onClose={() => setSwitcherOpen(false)}
/>
</>
);
}
......@@ -6,6 +6,7 @@
- 默认入口:`/self_check_the_results_of_the_working_draft`
- 页面菜单与 Subject 动作配置:`integrityPracticeConfig.js`
- 页面统一使用鑫元 HeroUI Pro Shell、`RecordWorkbench` 和 `RecordDetailDrawer`;Sidebar 为 offcanvas,移动端通过可访问性 Dialog 打开
- 应用切换继续读取现有 `GET /application/info` 和登录用户可访问应用,并按 `GET /info` 返回的 `site.group` 动态分组;未分组的可访问应用进入“其他应用”,无分组配置时回退“全部应用”,不改变鉴权、会话或跳转协议
- Subject 字段、Select 选项、列表视图和详情视图均从后端动态读取,页面不维护字段副本或硬编码选项
| 菜单分组 | 页面 | Subject |
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment