后台侧栏按业务分组支持折叠
Project CI / AI game creator shell Rust crates (push) Successful in 2m45s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 4m17s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 5m27s
Project CI / Frontend tests (push) Successful in 3m4s
Project CI / Backend tests (push) Successful in 6m32s
Project CI / Native shell tests (push) Successful in 7m36s
Project CI / AI game creator shell web tests (push) Successful in 3m24s
Project CI / Repository checks (push) Successful in 6m44s
Project CI / AI game creator shell Rust crates (push) Successful in 2m45s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 4m17s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 5m27s
Project CI / Frontend tests (push) Successful in 3m4s
Project CI / Backend tests (push) Successful in 6m32s
Project CI / Native shell tests (push) Successful in 7m36s
Project CI / AI game creator shell web tests (push) Successful in 3m24s
Project CI / Repository checks (push) Successful in 6m44s
新增后台侧栏业务分组与当前页自动展开行为 补充折叠交互、路由分组和权限过滤测试 同步后台导航分组行为文档
This commit is contained in:
@@ -0,0 +1,78 @@
|
|||||||
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { AdminSessionPayload } from '../api/adminApiTypes';
|
||||||
|
import { adminRoutes } from './adminRoutes';
|
||||||
|
import { AdminShell } from './AdminShell';
|
||||||
|
|
||||||
|
const admin: AdminSessionPayload = {
|
||||||
|
subject: 'admin-1',
|
||||||
|
username: 'owner',
|
||||||
|
displayName: '后台管理员',
|
||||||
|
roles: ['owner'],
|
||||||
|
accountRole: 'owner',
|
||||||
|
tabPermissions: [],
|
||||||
|
actionPermissions: [],
|
||||||
|
issuedAt: '2026-10-04T00:00:00.000Z',
|
||||||
|
expiresAt: '2026-10-05T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('侧边栏默认展开当前页分组并支持折叠其它分组', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onRouteChange = vi.fn();
|
||||||
|
const view = render(
|
||||||
|
<AdminShell
|
||||||
|
admin={admin}
|
||||||
|
routeId="dashboard"
|
||||||
|
routes={adminRoutes}
|
||||||
|
onLogout={vi.fn()}
|
||||||
|
onRouteChange={onRouteChange}
|
||||||
|
>
|
||||||
|
<div>页面内容</div>
|
||||||
|
</AdminShell>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebar = screen.getAllByRole('navigation', { name: '后台导航' })[0]!;
|
||||||
|
const workspaceToggle = within(sidebar).getByRole('button', {
|
||||||
|
name: /工作台/,
|
||||||
|
});
|
||||||
|
const observabilityToggle = within(sidebar).getByRole('button', {
|
||||||
|
name: /数据与监控/,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(workspaceToggle.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
expect(observabilityToggle.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
expect(
|
||||||
|
within(sidebar).getByRole('button', { name: '服务总览' }),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(sidebar).queryByRole('button', { name: '埋点数据' }),
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
await user.click(observabilityToggle);
|
||||||
|
expect(observabilityToggle.getAttribute('aria-expanded')).toBe('true');
|
||||||
|
await user.click(within(sidebar).getByRole('button', { name: '埋点数据' }));
|
||||||
|
expect(onRouteChange).toHaveBeenCalledWith('tracking');
|
||||||
|
|
||||||
|
await user.click(observabilityToggle);
|
||||||
|
expect(observabilityToggle.getAttribute('aria-expanded')).toBe('false');
|
||||||
|
view.rerender(
|
||||||
|
<AdminShell
|
||||||
|
admin={admin}
|
||||||
|
routeId="tracking"
|
||||||
|
routes={adminRoutes}
|
||||||
|
onLogout={vi.fn()}
|
||||||
|
onRouteChange={onRouteChange}
|
||||||
|
>
|
||||||
|
<div>页面内容</div>
|
||||||
|
</AdminShell>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
within(screen.getAllByRole('navigation', { name: '后台导航' })[0]!)
|
||||||
|
.getByRole('button', { name: /数据与监控/ })
|
||||||
|
.getAttribute('aria-expanded'),
|
||||||
|
).toBe('true');
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Activity,
|
Activity,
|
||||||
BadgeDollarSign,
|
BadgeDollarSign,
|
||||||
Bug,
|
Bug,
|
||||||
|
ChevronDown,
|
||||||
Coins,
|
Coins,
|
||||||
Database,
|
Database,
|
||||||
FolderArchive,
|
FolderArchive,
|
||||||
@@ -22,10 +23,15 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
WalletCards,
|
WalletCards,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import { type ReactNode, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import type { AdminSessionPayload } from '../api/adminApiTypes';
|
import type { AdminSessionPayload } from '../api/adminApiTypes';
|
||||||
import type { AdminRouteDefinition, AdminRouteId } from './adminRoutes';
|
import {
|
||||||
|
type AdminNavGroupId,
|
||||||
|
type AdminRouteDefinition,
|
||||||
|
type AdminRouteId,
|
||||||
|
getAdminNavGroups,
|
||||||
|
} from './adminRoutes';
|
||||||
|
|
||||||
interface AdminShellProps {
|
interface AdminShellProps {
|
||||||
admin: AdminSessionPayload;
|
admin: AdminSessionPayload;
|
||||||
@@ -72,6 +78,67 @@ export function AdminShell({
|
|||||||
onRouteChange,
|
onRouteChange,
|
||||||
onLogout,
|
onLogout,
|
||||||
}: AdminShellProps) {
|
}: AdminShellProps) {
|
||||||
|
const navGroups = getAdminNavGroups(routes);
|
||||||
|
const activeGroupId =
|
||||||
|
navGroups.find((group) =>
|
||||||
|
group.routes.some((route) => route.id === routeId),
|
||||||
|
)?.id ?? navGroups[0]?.id;
|
||||||
|
const [collapsedGroupIds, setCollapsedGroupIds] = useState<
|
||||||
|
Set<AdminNavGroupId>
|
||||||
|
>(() => {
|
||||||
|
return new Set(
|
||||||
|
navGroups
|
||||||
|
.map((group) => group.id)
|
||||||
|
.filter((groupId) => groupId !== activeGroupId),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeGroupId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCollapsedGroupIds((current) => {
|
||||||
|
if (!current.has(activeGroupId)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Set(current);
|
||||||
|
next.delete(activeGroupId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [activeGroupId, routeId]);
|
||||||
|
|
||||||
|
const toggleGroup = (groupId: AdminNavGroupId) => {
|
||||||
|
setCollapsedGroupIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(groupId)) {
|
||||||
|
next.delete(groupId);
|
||||||
|
} else {
|
||||||
|
next.add(groupId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRouteButton = (route: AdminRouteDefinition) => {
|
||||||
|
const Icon = routeIcons[route.id];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="admin-nav-button"
|
||||||
|
aria-current={route.id === routeId ? 'page' : undefined}
|
||||||
|
data-active={route.id === routeId}
|
||||||
|
key={route.id}
|
||||||
|
title={route.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRouteChange(route.id)}
|
||||||
|
>
|
||||||
|
<Icon size={18} aria-hidden="true" />
|
||||||
|
<span>{route.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-shell">
|
<div className="admin-shell">
|
||||||
<aside className="admin-sidebar">
|
<aside className="admin-sidebar">
|
||||||
@@ -86,20 +153,35 @@ export function AdminShell({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="admin-nav" aria-label="后台导航">
|
<nav className="admin-nav" aria-label="后台导航">
|
||||||
{routes.map((route) => {
|
{navGroups.map((group) => {
|
||||||
const Icon = routeIcons[route.id];
|
const isCollapsed = collapsedGroupIds.has(group.id);
|
||||||
|
const groupItemsId = `admin-nav-group-${group.id}`;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
className="admin-nav-button"
|
className="admin-nav-group"
|
||||||
data-active={route.id === routeId}
|
data-collapsed={isCollapsed}
|
||||||
key={route.id}
|
key={group.id}
|
||||||
title={route.label}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onRouteChange(route.id)}
|
|
||||||
>
|
>
|
||||||
<Icon size={18} aria-hidden="true" />
|
<button
|
||||||
<span>{route.label}</span>
|
aria-controls={groupItemsId}
|
||||||
</button>
|
aria-expanded={!isCollapsed}
|
||||||
|
className="admin-nav-group-toggle"
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleGroup(group.id)}
|
||||||
|
>
|
||||||
|
<ChevronDown size={15} aria-hidden="true" />
|
||||||
|
<span>{group.label}</span>
|
||||||
|
<small aria-hidden="true">{group.routes.length}</small>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="admin-nav-group-items"
|
||||||
|
data-collapsed={isCollapsed}
|
||||||
|
hidden={isCollapsed}
|
||||||
|
id={groupItemsId}
|
||||||
|
>
|
||||||
|
{group.routes.map(renderRouteButton)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -3,11 +3,46 @@ import { expect, test } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
adminRoutes,
|
adminRoutes,
|
||||||
getAccessibleAdminRoutes,
|
getAccessibleAdminRoutes,
|
||||||
|
getAdminNavGroups,
|
||||||
resolveAccessibleAdminRoute,
|
resolveAccessibleAdminRoute,
|
||||||
resolveAdminRoute,
|
resolveAdminRoute,
|
||||||
routeHash,
|
routeHash,
|
||||||
} from './adminRoutes';
|
} from './adminRoutes';
|
||||||
|
|
||||||
|
test('后台侧边栏路由按业务分组且不丢失任何页签', () => {
|
||||||
|
const groups = getAdminNavGroups(adminRoutes);
|
||||||
|
const groupedRouteIds = groups.flatMap((group) =>
|
||||||
|
group.routes.map((route) => route.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(groups.map((group) => group.label)).toEqual([
|
||||||
|
'工作台',
|
||||||
|
'数据与监控',
|
||||||
|
'运营配置',
|
||||||
|
'充值与支付',
|
||||||
|
'AGC 管理',
|
||||||
|
'内容运营',
|
||||||
|
'账号权限',
|
||||||
|
]);
|
||||||
|
expect(groupedRouteIds).toEqual(adminRoutes.map((route) => route.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('后台侧边栏分组只保留当前管理员可见的页签', () => {
|
||||||
|
const groups = getAdminNavGroups(
|
||||||
|
getAccessibleAdminRoutes({
|
||||||
|
accountRole: 'member',
|
||||||
|
tabPermissions: ['tracking', 'game-reviews', 'accounts'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
groups.map((group) => [group.label, group.routes.map((route) => route.id)]),
|
||||||
|
).toEqual([
|
||||||
|
['数据与监控', ['tracking']],
|
||||||
|
['内容运营', ['game-reviews']],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('游戏评价路由独立授权并支持游戏参数', () => {
|
test('游戏评价路由独立授权并支持游戏参数', () => {
|
||||||
expect(resolveAdminRoute('#game-reviews?gameId=game_1')).toBe('game-reviews');
|
expect(resolveAdminRoute('#game-reviews?gameId=game_1')).toBe('game-reviews');
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -71,6 +71,91 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
|||||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 桌面端侧边栏的导航分组。分组只影响展示顺序和折叠状态,不改变路由
|
||||||
|
* 权限、hash 或移动端底栏的平铺顺序。
|
||||||
|
*/
|
||||||
|
export type AdminNavGroupId =
|
||||||
|
| 'workspace'
|
||||||
|
| 'observability'
|
||||||
|
| 'operations'
|
||||||
|
| 'billing'
|
||||||
|
| 'content'
|
||||||
|
| 'agc'
|
||||||
|
| 'access';
|
||||||
|
|
||||||
|
export interface AdminNavGroupDefinition {
|
||||||
|
id: AdminNavGroupId;
|
||||||
|
label: string;
|
||||||
|
routeIds: AdminRouteId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminNavGroups: AdminNavGroupDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'workspace',
|
||||||
|
label: '工作台',
|
||||||
|
routeIds: ['dashboard', 'overview', 'tables', 'debug'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'observability',
|
||||||
|
label: '数据与监控',
|
||||||
|
routeIds: ['tracking', 'agc-tracking', 'error-reports'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'operations',
|
||||||
|
label: '运营配置',
|
||||||
|
routeIds: ['gray-release', 'redeem', 'invite', 'profile-wallet', 'tasks'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'billing',
|
||||||
|
label: '充值与支付',
|
||||||
|
routeIds: ['recharge-products', 'recharge-orders', 'payment-orders'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'agc',
|
||||||
|
label: 'AGC 管理',
|
||||||
|
routeIds: ['editor-generation-pricing', 'agc-models', 'agc-templates'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'content',
|
||||||
|
label: '内容运营',
|
||||||
|
routeIds: [
|
||||||
|
'editor-showcase',
|
||||||
|
'game-distribution',
|
||||||
|
'game-management',
|
||||||
|
'game-reviews',
|
||||||
|
'editor-assets',
|
||||||
|
'project-snapshots',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'access',
|
||||||
|
label: '账号权限',
|
||||||
|
routeIds: ['accounts'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface AdminNavGroup {
|
||||||
|
id: AdminNavGroupId;
|
||||||
|
label: string;
|
||||||
|
routes: AdminRouteDefinition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按固定业务分组筛选当前管理员可见的路由。 */
|
||||||
|
export function getAdminNavGroups(
|
||||||
|
routes: AdminRouteDefinition[],
|
||||||
|
): AdminNavGroup[] {
|
||||||
|
return adminNavGroups
|
||||||
|
.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
label: group.label,
|
||||||
|
routes: group.routeIds
|
||||||
|
.map((routeId) => routes.find((route) => route.id === routeId))
|
||||||
|
.filter((route): route is AdminRouteDefinition => route !== undefined),
|
||||||
|
}))
|
||||||
|
.filter((group) => group.routes.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminRouteAccess {
|
export interface AdminRouteAccess {
|
||||||
accountRole: 'owner' | 'member';
|
accountRole: 'owner' | 'member';
|
||||||
tabPermissions: string[];
|
tabPermissions: string[];
|
||||||
|
|||||||
@@ -289,7 +289,66 @@ button:disabled {
|
|||||||
|
|
||||||
.admin-nav {
|
.admin-nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-group-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 28px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #a08472;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-group-toggle svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
transition: transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-group[data-collapsed='true'] .admin-nav-group-toggle svg {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-group-toggle small {
|
||||||
|
min-width: 18px;
|
||||||
|
margin-left: auto;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
color: #b18d76;
|
||||||
|
background: #faf0e8;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-group-toggle:hover,
|
||||||
|
.admin-nav-group-toggle:focus-visible {
|
||||||
|
color: #8f3f27;
|
||||||
|
background: #fff8f2;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-group-items {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav-group-items[data-collapsed='true'] {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-nav-button,
|
.admin-nav-button,
|
||||||
@@ -305,8 +364,8 @@ button:disabled {
|
|||||||
.admin-nav-button {
|
.admin-nav-button {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-height: 42px;
|
min-height: 40px;
|
||||||
padding: 0 12px;
|
padding: 0 12px 0 28px;
|
||||||
color: #755a49;
|
color: #755a49;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
## 范围
|
## 范围
|
||||||
|
|
||||||
- 后台管理默认入口为 `#dashboard`,原服务 / 数据库状态页保留为 `#overview`,导航展示名为“服务总览”。
|
- 后台管理默认入口为 `#dashboard`,原服务 / 数据库状态页保留为 `#overview`,导航展示名为“服务总览”。
|
||||||
|
- 桌面端后台侧边栏按“工作台 / 数据与监控 / 运营配置 / 充值与支付 / AGC 管理 / 内容运营 / 账号权限”分组,默认只展开当前路由所在组。分组标题可独立折叠;每次切换路由(包括同组内跳转和浏览器 hash 导航)自动展开目标组,折叠动作不切换页面。只展示当前账号有权访问的页签,空组不渲染,组内数量只统计可访问页签。折叠状态仅保留在当前页面会话;分组不改变路由 hash、页签权限或移动端底栏的平铺顺序。
|
||||||
- Dashboard 由 `GET /admin/api/dashboard` 提供统一 BFF 投影,前端只展示后端返回的 `range`、`metrics`、`charts`、`operations` 和 `warnings`。
|
- Dashboard 由 `GET /admin/api/dashboard` 提供统一 BFF 投影,前端只展示后端返回的 `range`、`metrics`、`charts`、`operations` 和 `warnings`。
|
||||||
- 不新增持久化统计表或字段;profile / tracking 私有事实由仅 runtime service identity 可调用的 `get_admin_dashboard_stats_and_return` procedure 在同一事务快照内聚合,再经 `spacetime-client` facade 返回 api-server;素材与钱包继续复用原有查询。api-server 不再通过固定 `LIMIT` 拉取原始访问明细后自行拼装精确指标;该权威聚合失败时整个 Dashboard 请求失败,不用 0 伪装未知值。
|
- 不新增持久化统计表或字段;profile / tracking 私有事实由仅 runtime service identity 可调用的 `get_admin_dashboard_stats_and_return` procedure 在同一事务快照内聚合,再经 `spacetime-client` facade 返回 api-server;素材与钱包继续复用原有查询。api-server 不再通过固定 `LIMIT` 拉取原始访问明细后自行拼装精确指标;该权威聚合失败时整个 Dashboard 请求失败,不用 0 伪装未知值。
|
||||||
- 当前 profile / tracking 表没有覆盖这些跨 scope、跨日期统计的现成索引,因此 procedure 为保证精确性仍需遍历相关事实;上线后需监控调用耗时,数据规模继续增长时再以日期前缀索引或持久化日聚合事实替换,不能重新引入固定行数截断。
|
- 当前 profile / tracking 表没有覆盖这些跨 scope、跨日期统计的现成索引,因此 procedure 为保证精确性仍需遍历相关事实;上线后需监控调用耗时,数据规模继续增长时再以日期前缀索引或持久化日聚合事实替换,不能重新引入固定行数截断。
|
||||||
|
|||||||
Reference in New Issue
Block a user