oa-frontend-pretrial-cleanup 收敛PC试运行前页面风格
这个提交包含在:
@@ -21,3 +21,7 @@ reasonable.
|
||||
|
||||
Frozen modules and Enterprise WeChat-owned HR/attendance entries must be labeled
|
||||
or hidden according to the active Issue; do not silently re-enable them.
|
||||
- Use the shared `WorkspaceNotice` pattern for trial boundaries, safety notes,
|
||||
and post-trial capabilities instead of page-local warning blocks.
|
||||
- Trial-facing PC lists must hide obvious TEST/SMOKE records by default when a
|
||||
page-level toggle exists; the toggle may reveal them for troubleshooting.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "oa-frontend-pretrial-cleanup",
|
||||
"title": "OA 前端试运行前 PC 清障",
|
||||
"description": "试运行前收敛 PC 高频页面风格、文案、默认视图和冻结模块边界,补齐视觉证据,不改业务状态机。",
|
||||
"status": "planning",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "frontend-pretrial-cleanup",
|
||||
"package": null,
|
||||
|
||||
@@ -13,16 +13,16 @@ type PageHeaderProps = {
|
||||
|
||||
export function PageHeader({ title, subtitle, actions, eyebrow, meta, className }: PageHeaderProps) {
|
||||
return (
|
||||
<header className={cn("flex flex-wrap items-start justify-between gap-4", className)}>
|
||||
<header className={cn("flex flex-col gap-4 md:flex-row md:items-end md:justify-between", className)}>
|
||||
<div className="min-w-0 max-w-3xl">
|
||||
{eyebrow ? (
|
||||
<div className="text-xs font-bold uppercase tracking-[0.2em] text-primary">{eyebrow}</div>
|
||||
<div className="text-xs font-bold uppercase text-primary">{eyebrow}</div>
|
||||
) : null}
|
||||
<h2 className="mt-1 text-2xl font-semibold text-[var(--color-text-primary)]">{title}</h2>
|
||||
{subtitle ? <p className="mt-1.5 text-sm text-[var(--color-text-secondary)]">{subtitle}</p> : null}
|
||||
<h1 className="mt-1 text-2xl font-semibold text-[var(--color-text-primary)] md:text-3xl">{title}</h1>
|
||||
{subtitle ? <p className="mt-2 text-sm leading-6 text-[var(--color-text-secondary)]">{subtitle}</p> : null}
|
||||
{meta ? <div className="mt-3 flex flex-wrap gap-2">{meta}</div> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap gap-2">{actions}</div> : null}
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,3 +102,35 @@ export function WorkspaceEmptyState({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceNotice({
|
||||
title,
|
||||
children,
|
||||
tone = "info",
|
||||
actions,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
tone?: "info" | "warning" | "neutral";
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const toneClass = {
|
||||
info: "border-primary/20 bg-primary/5 text-primary",
|
||||
warning: "border-amber-200 bg-amber-50 text-amber-800",
|
||||
neutral: "border-slate-200 bg-slate-50 text-slate-700",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<section className={cn("rounded-xl border px-4 py-3 text-sm", toneClass, className)}>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold">{title}</div>
|
||||
{children ? <div className="mt-1 leading-6 text-slate-600">{children}</div> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
+11
-27
@@ -55,26 +55,15 @@
|
||||
.workbench-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 12% 4%, rgba(0, 57, 138, 0.14), transparent 17rem),
|
||||
radial-gradient(circle at 86% 12%, rgba(19, 168, 184, 0.15), transparent 19rem),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(230, 236, 244, 0.94));
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1px solid rgba(226, 232, 240, 0.92);
|
||||
border-radius: var(--radius-2xl);
|
||||
border-radius: var(--radius-panel);
|
||||
box-shadow: var(--shadow-sm);
|
||||
@apply flex flex-col gap-5 px-6 py-7 md:flex-row md:items-end md:justify-between md:px-7;
|
||||
@apply flex flex-col gap-4 px-5 py-5 md:flex-row md:items-end md:justify-between md:px-6;
|
||||
}
|
||||
|
||||
.workbench-hero::after {
|
||||
position: absolute;
|
||||
inset: auto -5rem -7rem auto;
|
||||
width: 18rem;
|
||||
height: 18rem;
|
||||
content: "";
|
||||
background:
|
||||
repeating-radial-gradient(ellipse at center, rgba(0, 57, 138, 0.13) 0 1px, transparent 1px 16px);
|
||||
border-radius: 9999px;
|
||||
transform: rotate(-8deg);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workbench-hero > * {
|
||||
@@ -84,17 +73,17 @@
|
||||
|
||||
.workbench-hero__eyebrow {
|
||||
color: var(--color-accent-primary);
|
||||
@apply text-xs font-bold uppercase tracking-[0.22em];
|
||||
@apply text-xs font-bold uppercase;
|
||||
}
|
||||
|
||||
.workbench-hero__title {
|
||||
color: var(--color-text-primary);
|
||||
@apply mt-2 text-3xl font-semibold tracking-tight md:text-4xl;
|
||||
@apply mt-1 text-2xl font-semibold md:text-3xl;
|
||||
}
|
||||
|
||||
.workbench-hero__subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
@apply mt-3 max-w-3xl text-sm leading-6 md:text-base md:leading-7;
|
||||
@apply mt-2 max-w-3xl text-sm leading-6;
|
||||
}
|
||||
|
||||
.workbench-hero--compact {
|
||||
@@ -135,16 +124,11 @@
|
||||
.section-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@apply p-5 md:p-6;
|
||||
@apply p-4 md:p-5;
|
||||
}
|
||||
|
||||
.section-panel::before {
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
content: "";
|
||||
background: linear-gradient(90deg, var(--color-accent-primary), rgba(19, 168, 184, 0.72), transparent);
|
||||
opacity: 0.7;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.section-panel__header {
|
||||
@@ -155,7 +139,7 @@
|
||||
|
||||
.section-panel__title {
|
||||
color: var(--color-text-primary);
|
||||
@apply text-base font-semibold tracking-tight;
|
||||
@apply text-base font-semibold;
|
||||
}
|
||||
|
||||
.section-panel__desc {
|
||||
@@ -166,7 +150,7 @@
|
||||
.section-panel__body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@apply mt-5;
|
||||
@apply mt-4;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
const TRIAL_NOISE_PATTERN = /(?:^|[^a-z0-9])(?:test|smoke)(?:$|[^a-z0-9]|\d)|测试|冒烟/i;
|
||||
|
||||
export function hasTrialNoise(...values: Array<string | null | undefined>) {
|
||||
return values.some((value) => TRIAL_NOISE_PATTERN.test(value ?? ""));
|
||||
}
|
||||
@@ -272,7 +272,7 @@ export function ApOperationsPage() {
|
||||
const canReviewRisk = hasPermission(permissions, "risk.review.manage");
|
||||
const canManageTravelPayments = hasPermission(permissions, "travel.payment.manage");
|
||||
|
||||
const [activeTab, setActiveTab] = useState<OperationsTab>("ledger");
|
||||
const [activeTab, setActiveTab] = useState<OperationsTab>("pending");
|
||||
const [pendingEntityType, setPendingEntityType] = useState<PendingEntityFilter>("all");
|
||||
const [pendingCreatedAtFrom, setPendingCreatedAtFrom] = useState("");
|
||||
const [pendingCreatedAtTo, setPendingCreatedAtTo] = useState("");
|
||||
@@ -618,7 +618,7 @@ export function ApOperationsPage() {
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="财务付款与对账"
|
||||
subtitle="集中处理待付款批次、异常付款、风险标记和对账异常。"
|
||||
subtitle="待付款、异常付款、风险和对账。"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
@@ -662,29 +662,29 @@ export function ApOperationsPage() {
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
<SummaryCard title="台账记录" value={financeLedgerQuery.data?.total ?? "-"} />
|
||||
<SummaryCard title="待付款" value={summaryQuery.data?.summary.pendingPaymentCount ?? "-"} />
|
||||
<SummaryCard title="异常" value={summaryQuery.data?.summary.exceptionCount ?? "-"} tone="red" />
|
||||
<SummaryCard
|
||||
title="部分匹配"
|
||||
value={summaryQuery.data?.summary.partialMatchCount ?? "-"}
|
||||
tone="amber"
|
||||
/>
|
||||
<SummaryCard title="异常" value={summaryQuery.data?.summary.exceptionCount ?? "-"} tone="red" />
|
||||
<SummaryCard title="台账记录" value={financeLedgerQuery.data?.total ?? "-"} />
|
||||
<SummaryCard
|
||||
title="预算超支"
|
||||
value={summaryQuery.data?.summary.budgetOverspendCount ?? "-"}
|
||||
tone="emerald"
|
||||
tone="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card-std p-4 md:p-5">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}>
|
||||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
||||
<TabsTrigger value="ledger">财务台账 {financeLedgerQuery.data?.total ?? 0}</TabsTrigger>
|
||||
<TabsTrigger value="pending">待付款 {pendingBatches.length}</TabsTrigger>
|
||||
<TabsTrigger value="failed">异常付款 {failedItems.length}</TabsTrigger>
|
||||
<TabsTrigger value="risk">风险标记 {filteredRiskFlags.length}</TabsTrigger>
|
||||
<TabsTrigger value="reconciliation">对账异常 {partialMatchItems.length}</TabsTrigger>
|
||||
<TabsTrigger value="ledger">财务台账 {financeLedgerQuery.data?.total ?? 0}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ledger" className="mt-6 space-y-4">
|
||||
|
||||
@@ -34,7 +34,6 @@ import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
@@ -1415,10 +1414,15 @@ export function AssetRegistryPage() {
|
||||
) : null}
|
||||
|
||||
{selectedAssetId && !isCreatingAsset && canIntegrationManage ? (
|
||||
<SectionPanel
|
||||
title="ERP 同步状态"
|
||||
description="仅同步资产法体、成本中心与退役/归档等财务台账摘要,不接管实物流转。"
|
||||
>
|
||||
<details className="card-std p-4 md:p-5">
|
||||
<summary className="flex cursor-pointer list-none items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-zinc-900">ERP 同步状态</div>
|
||||
<div className="mt-1 text-sm text-zinc-500">高级轨道,默认收起。</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-primary">展开</span>
|
||||
</summary>
|
||||
<div className="mt-4">
|
||||
{assetErpTrackQuery.isLoading ? (
|
||||
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">ERP 同步轨道加载中...</p>
|
||||
) : assetErpTrackQuery.isError ? (
|
||||
@@ -1476,7 +1480,8 @@ export function AssetRegistryPage() {
|
||||
) : (
|
||||
<p className="rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-500">暂无 ERP 轨道信息</p>
|
||||
)}
|
||||
</SectionPanel>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{selectedAssetId && !isCreatingAsset && currentAsset ? (
|
||||
|
||||
@@ -21,6 +21,7 @@ import { PageHeader } from "../components/common/PageHeader";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
@@ -247,6 +248,9 @@ export function AttendancePage() {
|
||||
return (
|
||||
<PageScaffoldLike>
|
||||
<PageHeader title="考勤" subtitle="日常打卡在企业微信,审批和解释在 OA。" />
|
||||
<WorkspaceNotice title="企业微信主办" tone="info">
|
||||
日常打卡、请假和假期余额由企业微信承载;OA 只保留汇总、解释和审计入口。
|
||||
</WorkspaceNotice>
|
||||
<SectionPanel title="企微考勤统一汇总未启用">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
当前 `attendance_unified_v1` 未开启。历史 OA 自建上下班打卡已关闭;正式启用前请由管理员打开统一考勤功能开关。
|
||||
@@ -259,6 +263,9 @@ export function AttendancePage() {
|
||||
return (
|
||||
<PageScaffoldLike>
|
||||
<PageHeader title="考勤" subtitle="企业微信采集打卡;OA 统一处理请假、补卡、外出、加班和月度解释。" />
|
||||
<WorkspaceNotice title="企业微信主办" tone="info">
|
||||
日常打卡仍在企业微信完成;OA 不写回企业微信,只展示汇总、修正审批和同步审计。
|
||||
</WorkspaceNotice>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<MetricCard title="人员" value={monthly?.summary.employeeCount ?? 0} />
|
||||
|
||||
@@ -18,6 +18,7 @@ import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
@@ -377,8 +378,11 @@ export function BudgetsPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="预算管理"
|
||||
subtitle="支持预算池维护,并在同一页面下维护预算控制规则中心。"
|
||||
subtitle="预算池和控制规则维护。"
|
||||
/>
|
||||
<WorkspaceNotice title="后置能力" tone="warning">
|
||||
试运行主路径不启用预算硬控;本页仅供管理员维护规则和查看配置。
|
||||
</WorkspaceNotice>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -24,12 +24,14 @@ import { hasAnyPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { downloadBlob } from "../lib/download";
|
||||
import { formatDisplayDate } from "../lib/displayFormat";
|
||||
import { hasTrialNoise } from "../lib/trialNoise";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const CONFIDENTIALITY_OPTIONS: ConfidentialityLevel[] = ["public", "internal", "confidential", "restricted"];
|
||||
@@ -200,6 +202,7 @@ export function ContractsRegistryPage() {
|
||||
const [editorId, setEditorId] = useState<string | null>(null);
|
||||
const [editor, setEditor] = useState<EditorState>(() => buildEmptyEditor(initialProjectId));
|
||||
const [showExpiring, setShowExpiring] = useState(false);
|
||||
const [showTrialData, setShowTrialData] = useState(false);
|
||||
|
||||
const filterPayload = buildFiltersPayload(filters);
|
||||
const contractsQuery = useQuery({
|
||||
@@ -223,6 +226,16 @@ export function ContractsRegistryPage() {
|
||||
const projectOptions = projectsQuery.data?.items ?? [];
|
||||
const employeeOptions = canBrowseEmployees ? employeesQuery.data?.items ?? [] : [];
|
||||
const contracts = contractsQuery.data?.items ?? [];
|
||||
const visibleContracts = useMemo(
|
||||
() =>
|
||||
showTrialData
|
||||
? contracts
|
||||
: contracts.filter(
|
||||
(item) => !hasTrialNoise(item.contractNo, item.counterpartyName, item.summary, item.project?.name),
|
||||
),
|
||||
[contracts, showTrialData],
|
||||
);
|
||||
const hiddenTrialContractCount = contracts.length - visibleContracts.length;
|
||||
const expiringItems = expiringQuery.data?.items ?? [];
|
||||
const editingItem = editorId ? contracts.find((item) => item.id === editorId) ?? null : null;
|
||||
const readonlyOwnerLabel = editingItem?.owner
|
||||
@@ -633,24 +646,29 @@ export function ContractsRegistryPage() {
|
||||
<h3 className="text-base font-semibold text-zinc-900">台账列表</h3>
|
||||
<div className="mt-1 text-sm text-zinc-500">{filters.projectId ? "已按项目过滤" : "显示全部可见合同"}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setFilters({
|
||||
projectId: initialProjectId,
|
||||
status: "",
|
||||
contractType: "",
|
||||
counterpartyName: "",
|
||||
expiryDateFrom: "",
|
||||
expiryDateTo: "",
|
||||
});
|
||||
setEditorId(null);
|
||||
setEditor(buildEmptyEditor(initialProjectId));
|
||||
}}
|
||||
>
|
||||
重置筛选
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setFilters({
|
||||
projectId: initialProjectId,
|
||||
status: "",
|
||||
contractType: "",
|
||||
counterpartyName: "",
|
||||
expiryDateFrom: "",
|
||||
expiryDateTo: "",
|
||||
});
|
||||
setEditorId(null);
|
||||
setEditor(buildEmptyEditor(initialProjectId));
|
||||
}}
|
||||
>
|
||||
重置筛选
|
||||
</Button>
|
||||
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
|
||||
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
@@ -733,8 +751,14 @@ export function ContractsRegistryPage() {
|
||||
<div className="text-sm text-red-600">{resolveError(contractsQuery.error, "加载合同台账失败")}</div>
|
||||
) : null}
|
||||
|
||||
{!showTrialData && hiddenTrialContractCount > 0 ? (
|
||||
<WorkspaceNotice title={`已隐藏 ${hiddenTrialContractCount} 条 TEST/SMOKE 合同`} tone="neutral">
|
||||
试运行默认只展示正式合同;需要核对测试样本时可临时显示。
|
||||
</WorkspaceNotice>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{contracts.map((item) => (
|
||||
{visibleContracts.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-200 p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
@@ -802,7 +826,7 @@ export function ContractsRegistryPage() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!contractsQuery.isLoading && !contracts.length ? (
|
||||
{!contractsQuery.isLoading && !visibleContracts.length ? (
|
||||
<div className="text-sm text-zinc-500">暂无符合筛选条件的合同台账。</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -320,6 +321,9 @@ export function CutoverConsolePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="切换控制台" subtitle="范围开关和导入演练。" />
|
||||
<WorkspaceNotice title="仅管理入口" tone="warning">
|
||||
切换范围会影响试运行边界,默认不作为日常业务入口;正式变更需另行确认。
|
||||
</WorkspaceNotice>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="card-std p-5">
|
||||
|
||||
@@ -21,6 +21,7 @@ import { isApiError } from "../api/types";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -262,7 +263,7 @@ export function ErpIntegrationPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="ERP 集成"
|
||||
subtitle="主数据同步、业务同步队列和 ID 映射。"
|
||||
subtitle="主数据同步、业务队列和 ID 映射。"
|
||||
actions={
|
||||
tab === "master-data" ? (
|
||||
<Button onClick={() => syncMutation.mutate(undefined)} disabled={syncMutation.isPending}>
|
||||
@@ -271,6 +272,9 @@ export function ErpIntegrationPage() {
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<WorkspaceNotice title="ERP 写入后置" tone="warning">
|
||||
试运行期间不把 ERP 写入作为主路径;本页用于管理员查看同步状态和队列排障。
|
||||
</WorkspaceNotice>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -318,8 +319,11 @@ export function FeatureFlagsPage() {
|
||||
<div className={batchMode ? "space-y-6 pb-32" : "space-y-6"}>
|
||||
<PageHeader
|
||||
title="功能开关"
|
||||
subtitle="统一治理外部桥接、验真与请假日历灰度。Env 仍是硬闸,在线开关用于部门级灰度。"
|
||||
subtitle="外部桥接、验真和灰度开关。"
|
||||
/>
|
||||
<WorkspaceNotice title="安全摘要" tone="warning">
|
||||
Env 仍是硬闸;批量开启、关闭或灰度变更会影响试运行边界,执行前必须保留变更说明。
|
||||
</WorkspaceNotice>
|
||||
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||
@@ -327,7 +331,7 @@ export function FeatureFlagsPage() {
|
||||
|
||||
<SectionPanel
|
||||
title="开关列表"
|
||||
description="单条编辑保留原能力;批量模式只统一切换 overrideMode,并要求共享变更说明。"
|
||||
description="单条编辑或批量切换,必须填写变更说明。"
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -639,8 +639,7 @@ function QuickActionsPanel({
|
||||
}) {
|
||||
return (
|
||||
<SectionPanel
|
||||
title="快捷操作栏"
|
||||
description="高频动作"
|
||||
title="常用"
|
||||
className={layout === "rail" ? "portal-panel-fill" : undefined}
|
||||
>
|
||||
<div className={layout === "rail" ? "portal-action-stack" : "grid gap-3 sm:grid-cols-2 xl:grid-cols-4"}>
|
||||
@@ -667,8 +666,7 @@ function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
|
||||
|
||||
return (
|
||||
<SectionPanel
|
||||
title="今日待办聚合"
|
||||
description="今天要处理的事"
|
||||
title="待处理"
|
||||
className="portal-panel-fill"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
@@ -713,8 +711,7 @@ function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) {
|
||||
|
||||
return (
|
||||
<SectionPanel
|
||||
title="团队全员状态"
|
||||
description="今日状态"
|
||||
title="团队状态"
|
||||
actions={<Link to="/team" className="text-sm font-medium text-primary hover:underline">查看团队页</Link>}
|
||||
className="portal-panel-fill"
|
||||
>
|
||||
@@ -772,8 +769,7 @@ function ActivityAnnouncementsPanel({
|
||||
}) {
|
||||
return (
|
||||
<SectionPanel
|
||||
title="近期动态流"
|
||||
description="动态和公告"
|
||||
title="通知"
|
||||
actions={<Link to="/announcements" className="text-sm font-medium text-primary hover:underline">公告页</Link>}
|
||||
className="portal-panel-fill"
|
||||
>
|
||||
@@ -1156,8 +1152,8 @@ export function PortalHomePage() {
|
||||
{isManagerView ? (
|
||||
<>
|
||||
<SecondaryWorkspacePanel
|
||||
title="角色重点 / 常用入口"
|
||||
description="按需展开。"
|
||||
title="角色重点"
|
||||
description="常用入口"
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)] 2xl:grid-cols-[minmax(0,1fr),minmax(0,1fr),360px]">
|
||||
{roleCapabilities.canApproveTasks ? (
|
||||
@@ -1185,8 +1181,8 @@ export function PortalHomePage() {
|
||||
</div>
|
||||
|
||||
<SecondaryCardsPanel
|
||||
title="项目跟进与组织协同"
|
||||
description="项目、组织和风险。"
|
||||
title="项目与组织"
|
||||
description="项目、组织、风险"
|
||||
cards={secondaryCards}
|
||||
/>
|
||||
</SecondaryWorkspacePanel>
|
||||
@@ -1196,7 +1192,7 @@ export function PortalHomePage() {
|
||||
) : (
|
||||
<SecondaryWorkspacePanel
|
||||
title="我的申请与资产 / 继续工作"
|
||||
description="按需展开。"
|
||||
description="申请、资产、最近访问"
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
|
||||
<SummaryPanel title="我的申请与资产" items={employeeSummaryItems} />
|
||||
@@ -1206,8 +1202,8 @@ export function PortalHomePage() {
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
|
||||
<QuickLinksPanel items={quickLinkItems} />
|
||||
<SecondaryCardsPanel
|
||||
title="项目跟进与个人协同"
|
||||
description="项目和参考指标。"
|
||||
title="项目与协同"
|
||||
description="项目、参考指标"
|
||||
cards={secondaryCards}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { FilterBar, ListWorkspace, StatStrip, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import { FilterBar, ListWorkspace, StatStrip, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -22,6 +22,7 @@ import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { formatDisplayDate } from "../lib/displayFormat";
|
||||
import { hasTrialNoise } from "../lib/trialNoise";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const STATUS_OPTIONS: Array<{ value: "all" | ProjectStatus; label: string }> = [
|
||||
@@ -76,6 +77,7 @@ export function ProjectsPage() {
|
||||
const [startsAt, setStartsAt] = useState("");
|
||||
const [targetEndAt, setTargetEndAt] = useState("");
|
||||
const [isConfidential, setIsConfidential] = useState(false);
|
||||
const [showTrialData, setShowTrialData] = useState(false);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["projects", "list", status, mine, q],
|
||||
@@ -99,8 +101,18 @@ export function ProjectsPage() {
|
||||
);
|
||||
|
||||
const projectItems = listQuery.data?.items ?? [];
|
||||
const visibleProjectItems = useMemo(
|
||||
() =>
|
||||
showTrialData
|
||||
? projectItems
|
||||
: projectItems.filter(
|
||||
(item) => !hasTrialNoise(item.projectNo, item.name, item.clientName, item.clientAlias, item.summary),
|
||||
),
|
||||
[projectItems, showTrialData],
|
||||
);
|
||||
const hiddenTrialProjectCount = projectItems.length - visibleProjectItems.length;
|
||||
const projectStats = useMemo(() => {
|
||||
return projectItems.reduce(
|
||||
return visibleProjectItems.reduce(
|
||||
(acc, item) => {
|
||||
acc.total += 1;
|
||||
if (item.status === "active") {
|
||||
@@ -112,7 +124,7 @@ export function ProjectsPage() {
|
||||
},
|
||||
{ total: 0, active: 0, overdueMilestones: 0, openRisks: 0 },
|
||||
);
|
||||
}, [projectItems]);
|
||||
}, [visibleProjectItems]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -183,7 +195,7 @@ export function ProjectsPage() {
|
||||
|
||||
<div className="page-shell-grid">
|
||||
<div className="space-y-6">
|
||||
<FilterBar className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px,160px]">
|
||||
<FilterBar className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px,160px,160px]">
|
||||
<Input placeholder="搜索项目编号、项目名、客户名" value={q} onChange={(event) => updateSearch({ q: event.target.value })} />
|
||||
<select className="select-std" value={status} onChange={(event) => updateSearch({ status: event.target.value })}>
|
||||
{STATUS_OPTIONS.map((item) => (
|
||||
@@ -195,18 +207,27 @@ export function ProjectsPage() {
|
||||
<Button type="button" variant={mine ? "default" : "secondary"} onClick={() => updateSearch({ mine: !mine })}>
|
||||
{mine ? "只看我的" : "切换到我的"}
|
||||
</Button>
|
||||
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
|
||||
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
|
||||
</Button>
|
||||
</FilterBar>
|
||||
|
||||
{!showTrialData && hiddenTrialProjectCount > 0 ? (
|
||||
<WorkspaceNotice title={`已隐藏 ${hiddenTrialProjectCount} 条 TEST/SMOKE 项目`} tone="neutral">
|
||||
试运行默认只展示正式项目;需要排障时可临时显示测试数据。
|
||||
</WorkspaceNotice>
|
||||
) : null}
|
||||
|
||||
<SectionPanel
|
||||
title="项目列表"
|
||||
description={projectItems.length ? `当前筛出 ${projectItems.length} 个项目` : "按项目名、客户或状态快速缩小范围"}
|
||||
description={visibleProjectItems.length ? `当前显示 ${visibleProjectItems.length} 个项目` : "按项目名、客户或状态快速缩小范围"}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{listQuery.isLoading ? <div className="text-sm text-zinc-500">项目加载中...</div> : null}
|
||||
{listQuery.isError ? <div className="text-sm text-red-600">{resolveError(listQuery.error, "加载项目失败")}</div> : null}
|
||||
|
||||
<div className="grid gap-4">
|
||||
{projectItems.map((item) => (
|
||||
{visibleProjectItems.map((item) => (
|
||||
<Link key={item.id} to={`/projects/${item.id}`} className="card-std block space-y-3 p-5 hover:border-primary/40">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
@@ -228,7 +249,7 @@ export function ProjectsPage() {
|
||||
<div className="text-sm text-zinc-500">{summaryLine(item)}</div>
|
||||
</Link>
|
||||
))}
|
||||
{!listQuery.isLoading && !projectItems.length ? (
|
||||
{!listQuery.isLoading && !visibleProjectItems.length ? (
|
||||
<div className="rounded-xl border border-dashed border-zinc-200 bg-zinc-50 px-4 py-8 text-sm text-zinc-500">
|
||||
当前没有符合筛选条件的项目。
|
||||
</div>
|
||||
|
||||
@@ -363,7 +363,7 @@ export function ReimbursementsPage() {
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="我的报销"
|
||||
subtitle="草稿、审批、支付。"
|
||||
subtitle="草稿、审批和付款状态。"
|
||||
actions={
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
{showProxyOwnerSelector ? (
|
||||
|
||||
@@ -28,6 +28,7 @@ import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
||||
import { EmptyStateCard } from "../components/common/EmptyStateCard";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
||||
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
@@ -517,13 +518,16 @@ export function TravelRequestsPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="差旅管理"
|
||||
subtitle="差旅申请、对公支付轨道与批次对账在同一路径统一收口。"
|
||||
subtitle="差旅申请、支付轨道和批次对账。"
|
||||
actions={
|
||||
activeTab === "requests" && canCreate ? (
|
||||
<Button type="button" onClick={() => selectTravelRequest("")}>新建差旅申请</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<WorkspaceNotice title="后置能力" tone="warning">
|
||||
差旅不是本次 7 天试运行主路径;如需使用,仅按已有权限查看或维护,不扩展新流程。
|
||||
</WorkspaceNotice>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canAccessRequestsTab ? (
|
||||
|
||||
@@ -210,7 +210,7 @@ export function WorkflowTasksPage() {
|
||||
<WorkspaceHeader
|
||||
eyebrow="审批"
|
||||
title="审批中心"
|
||||
subtitle="集中处理待办、查看已办和业务审批历史。"
|
||||
subtitle="待处理、已处理和业务类型。"
|
||||
/>
|
||||
|
||||
<div data-testid="workflow-task-summary">
|
||||
|
||||
在新工单中引用
屏蔽一个用户