forked from ChaiHao/BPOS-MVP
基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -9,7 +9,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Session, TaskCard, TaskDetail } from '@contracts';
|
||||
import { listApprovalsForMeRoute, listMessagesRoute, listTasksRoute, replyWorkOrderRoute, sayRoute, sessionRoute, taskDetailRoute } from '@contracts';
|
||||
import {
|
||||
listApprovalsForMeRoute,
|
||||
listMessagesRoute,
|
||||
listTasksRoute,
|
||||
logoutRoute,
|
||||
sessionRoute,
|
||||
taskDetailRoute,
|
||||
} from '@contracts';
|
||||
import { TaskChat, TaskList, ViewRegion } from '@features/desk-task';
|
||||
import { ApprovalDesk } from '@features/desk-approval';
|
||||
import { MessageDesk } from '@features/desk-message';
|
||||
@@ -37,6 +44,7 @@ export function AppShell() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const split = useChatWidth();
|
||||
|
||||
|
||||
const session = useQuery({
|
||||
queryKey: queryKeys.session,
|
||||
queryFn: () => call<Session>(sessionRoute),
|
||||
@@ -67,24 +75,20 @@ export function AppShell() {
|
||||
enabled: current !== '',
|
||||
});
|
||||
|
||||
const reply = useMutation({
|
||||
mutationFn: (payload: Record<string, unknown>) =>
|
||||
call<{ ok: boolean }>(replyWorkOrderRoute, { body: { reply: payload } }),
|
||||
// 回传后按目标失效缓存 —— 组件不自行轮询
|
||||
|
||||
|
||||
|
||||
/** 登出后**整体清空缓存**:上一个人的任务、审批、消息不能留在下一个人眼前 */
|
||||
const logout = useMutation({
|
||||
mutationFn: () => call<{ ok: boolean }>(logoutRoute),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.task(current) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.tasks });
|
||||
queryClient.clear();
|
||||
// 整页导航:SSE 连接、组件状态、路由位置一并归零 —— 登出不留残影
|
||||
window.location.assign('/');
|
||||
},
|
||||
});
|
||||
|
||||
const say = useMutation({
|
||||
mutationFn: (text: string) => call(sayRoute, { params: { taskId: current }, body: { text } }),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: queryKeys.task(current) }),
|
||||
});
|
||||
|
||||
const me = session.data?.staff.name ?? '—';
|
||||
// composer 锁:有卡在等作答时锁住自由输入
|
||||
const locked = (detail.data?.workOrders ?? []).some((o) => o.done === undefined);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
@@ -119,10 +123,17 @@ export function AppShell() {
|
||||
);
|
||||
})}
|
||||
<div className="jc-rail-foot">
|
||||
<div className="jc-nav-user" title={`${me}(本工作台所有者)`}>
|
||||
{/* 轨底身份即登出入口 —— 会话在服务端,点它是真作废,不只是清本地 */}
|
||||
<button
|
||||
type="button"
|
||||
className="jc-nav-user"
|
||||
title={`${me}(本工作台所有者)· 点击登出`}
|
||||
disabled={logout.isPending}
|
||||
onClick={() => logout.mutate()}
|
||||
>
|
||||
<span className="jc-nav-ava">{me.slice(0, 1)}</span>
|
||||
<span className="jc-nav-name">{me}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -147,9 +158,9 @@ export function AppShell() {
|
||||
onCollapse={() => setCollapsed(true)}
|
||||
/>
|
||||
{detail.data !== undefined ? (
|
||||
<ViewRegion key={current} detail={detail.data} onReply={(payload) => reply.mutate(payload)} />
|
||||
<ViewRegion key={current} detail={detail.data} />
|
||||
) : (
|
||||
<main className="execution-panel jc-panel-card">
|
||||
<main className="execution-panel">
|
||||
<div className="jc-list-empty">
|
||||
<Ico name="list-checks" s={18} />
|
||||
{tasks.isLoading ? '载入中…' : '还没有落到你工位的任务'}
|
||||
@@ -165,13 +176,9 @@ export function AppShell() {
|
||||
/>
|
||||
)}
|
||||
{detail.data !== undefined && (
|
||||
<TaskChat
|
||||
key={current}
|
||||
chat={detail.data.chat}
|
||||
locked={locked}
|
||||
onSay={(text) => say.mutate(text)}
|
||||
onDecide={() => undefined}
|
||||
/>
|
||||
<section className="session-chat">
|
||||
<TaskChat key={current} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
) : module === 'approvals' ? (
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
* 刷新与深链都能完整还原;前端零过程状态。
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { logoutRoute } from '@contracts';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { TABS, ZM, ZONES, routeLabel } from '@web-shared/gov/gov-route.ts';
|
||||
import type { GovZone, Nav, Route } from '@web-shared/gov/gov-route.ts';
|
||||
@@ -48,6 +51,17 @@ export function GovShell() {
|
||||
const [place, setPlace] = useState<Place>(readPlace);
|
||||
const staff = useGovStaff();
|
||||
const tasks = useGovTasks();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/** 登出后整体清空缓存 —— 治理台看到的是全组织数据,更不能留给下一个人 */
|
||||
const logout = useMutation({
|
||||
mutationFn: () => call<{ ok: boolean }>(logoutRoute),
|
||||
onSuccess: () => {
|
||||
queryClient.clear();
|
||||
// 整页导航:SSE 连接、组件状态、路由位置一并归零 —— 登出不留残影
|
||||
window.location.assign('/');
|
||||
},
|
||||
});
|
||||
|
||||
// 浏览器前进 / 后退即导航 —— 路由状态就是应用状态
|
||||
useEffect(() => {
|
||||
@@ -145,6 +159,10 @@ export function GovShell() {
|
||||
<Ico name="list-checks" s={13} />
|
||||
工作台
|
||||
</a>
|
||||
<button className="g-tbtn" onClick={() => logout.mutate()} disabled={logout.isPending}>
|
||||
<Ico name="log-out" s={13} />
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
+81
-16
@@ -1,34 +1,99 @@
|
||||
/**
|
||||
* apps/web 唯一组装层(编码规范 §9):Provider、鉴权壳、SSE 连接。
|
||||
* 样式整文件搬自两台原型:设计令牌 + 全部 `jc-*` 组件样式 + vendor 皮肤。
|
||||
* apps/web 唯一组装层(编码规范 §9):样式装载、Provider、鉴权壳、SSE 连接。
|
||||
*
|
||||
* **两台面各自装自己的皮肤。**
|
||||
* 两份原型本是两个独立应用,各自只加载自己那两份 CSS:
|
||||
* 工作台 = `proto.vendor.css`(原型皮肤)+ `app.css`(补充,作用域 `.proto-root`)
|
||||
* 治理台 = `theme.css`(令牌 + 页面基座)+ `console.css`(gov-* 组件)
|
||||
* 两份基座都写了 `body { min-width / background }` 这类**全局**规则,谁后加载谁赢 ——
|
||||
* 四份一起塞进同一个文档,后加载的治理台基座会把工作台的壳整个盖掉(实测:布局全崩)。
|
||||
* 所以按台面**分别装载**,这也正是 1:1 平移该有的样子:每台面拿到的样式集与它的原型一致。
|
||||
*/
|
||||
import { StrictMode, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import './styles/proto.vendor.css';
|
||||
import './styles/app.css';
|
||||
import './styles/theme.css';
|
||||
import './styles/console.css';
|
||||
import { QueryClientProvider, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Session } from '@contracts';
|
||||
import { sessionRoute } from '@contracts';
|
||||
import { AppShell } from './app-shell.tsx';
|
||||
import { connectStream } from './subscribe.ts';
|
||||
import { GovShell } from './gov-shell.tsx';
|
||||
import { queryClient } from './query.ts';
|
||||
import { LoginPage } from '@features/auth';
|
||||
import { ApiCallError, call } from '@web-shared/typed-fetch.ts';
|
||||
import { queryClient, queryKeys } from './query.ts';
|
||||
import { applyTabIdentity } from './tab-identity.ts';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) throw new Error('#root 缺失');
|
||||
|
||||
/**
|
||||
* 两台面共一个 SPA:路径决定进哪台。
|
||||
* 路由状态即应用状态 —— 可深链(§9.2);TanStack Router 的类型化 search params 随后续接入。
|
||||
/** 路径或 hash 决定进哪台 —— 路由状态即应用状态,可深链(§9.2) */
|
||||
const isGovernance =
|
||||
window.location.pathname.startsWith('/gov') || window.location.hash.startsWith('#/gov');
|
||||
|
||||
// 网页标签的名与章 —— 样式装载前就挂上,标签从第一帧起就叫对名字
|
||||
applyTabIdentity(isGovernance);
|
||||
|
||||
/*
|
||||
* 样式先装、再渲染:CSS 落地后才挂 DOM,避免无样式闪烁。
|
||||
* 台面切换靠整页导航(两台面的 body 基座互斥,不做客户端软切换)。
|
||||
* 登录页两台面共用,故恒装 —— 它通篇作用域在 `.jc-login`,不与任一基座相争。
|
||||
*/
|
||||
await import('./styles/login.css');
|
||||
if (isGovernance) {
|
||||
await import('./styles/theme.css');
|
||||
await import('./styles/console.css');
|
||||
} else {
|
||||
await import('./styles/proto.vendor.css');
|
||||
await import('./styles/app.css');
|
||||
}
|
||||
|
||||
/**
|
||||
* 鉴权壳(ADR-010)—— 会话是**服务端**的事:401 即落登录页。
|
||||
* 前端不自存身份,刷新照样问一次 `/api/session`;登出后缓存整体清空,
|
||||
* 免得上一个人的任务、审批、消息留在下一个人眼前。
|
||||
*/
|
||||
function Gate() {
|
||||
const client = useQueryClient();
|
||||
const session = useQuery({
|
||||
queryKey: queryKeys.session,
|
||||
queryFn: () => call<Session>(sessionRoute),
|
||||
// 未登录是正常状态,不是故障 —— 不重试,直接落登录页
|
||||
retry: (_count, error) => !(error instanceof ApiCallError && error.status === 401),
|
||||
});
|
||||
|
||||
/*
|
||||
* 判据要看 error,不能只看 data。
|
||||
* TanStack Query 在**重取失败时保留上一次成功的 data** —— 会话过期或登出后重取拿到 401,
|
||||
* `session.data` 依旧是旧身份;只认 data 就会停在一个空壳里,退不出去(实测如此)。
|
||||
*/
|
||||
const expired = session.error instanceof ApiCallError && session.error.status === 401;
|
||||
const authed = session.data !== undefined && !expired;
|
||||
|
||||
// SSE 只在登录后连:未登录时那条连接会被中间件 401 挡下
|
||||
useEffect(() => (authed ? connectStream(client) : undefined), [authed, client]);
|
||||
|
||||
if (session.isPending) return null;
|
||||
if (!authed) {
|
||||
// 只落身份,**不要 clear()**:clear 会连同已挂载的观察者一起移除,
|
||||
// 随后 setQueryData 建的是新条目,老观察者接不上 —— 界面会卡在「登录中…」(实测)。
|
||||
// 缓存的清空由登出那侧的整页跳转保证,这里到达时本就是空的。
|
||||
return <LoginPage onLogin={(s) => client.setQueryData(queryKeys.session, s)} />;
|
||||
}
|
||||
|
||||
return isGovernance ? (
|
||||
<GovShell />
|
||||
) : (
|
||||
// .proto-root 是工作台补充样式的作用域(原型 app.css 通篇以它起头);
|
||||
// 治理台不裹它 —— 裹了工作台的补充样式就会漏进治理台的树里
|
||||
<div className="proto-root">
|
||||
<AppShell />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Root() {
|
||||
// 单一 SSE 连接在组装层建立;feature 只管订阅缓存键,不自建连接、不轮询
|
||||
useEffect(() => connectStream(queryClient), []);
|
||||
const governance = window.location.pathname.startsWith('/gov') || window.location.hash.startsWith('#/gov');
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{/* .proto-root 是设计令牌的作用域(原型 app.css 的 `.proto-root .app-shell`) */}
|
||||
<div className="proto-root">{governance ? <GovShell /> : <AppShell />}</div>
|
||||
<Gate />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
/* ===== 三栏衔接:列表区 / 视图区 / 对话区统一为三张等距同规格卡片 =====
|
||||
覆盖 vendor 的「左中融合 + 右侧浮卡」旧结构:等间距 gutter、同圆角、同描边、同投影 */
|
||||
/* 对话区宽度 = var(--jc-chat-w)(用户拖拽分隔柄写入 workspace inline style),未拖时走各断点默认 */
|
||||
.proto-root .workspace { position: relative; gap: 12px; padding: 12px 14px; --jc-ws-pr: 14px; --jcai-ai-chat-width: var(--jc-chat-w, 440px); grid-template-columns: 310px minmax(0, 1fr) var(--jcai-ai-chat-width); }
|
||||
.proto-root .workspace { position: relative; gap: 12px; padding: 12px 14px; --jc-ws-pr: 14px; --jcai-ai-chat-width: var(--jc-chat-w, 440px); grid-template-columns: 310px minmax(var(--jc-work-min), 1fr) minmax(0, var(--jcai-ai-chat-width)); }
|
||||
/* 收起态 grid:无媒体查询、全断点生效,覆盖 vendor 各断点的三栏定义(修列错位 bug)。
|
||||
清单收起→[视图区 1fr | 会话区];会话收起→[清单 | 视图区 1fr];都收起→[视图区 1fr]。 */
|
||||
.proto-root .workspace.jc-side-collapsed:not(.jc-chat-collapsed) { grid-template-columns: minmax(0, 1fr) var(--jcai-ai-chat-width, 440px); }
|
||||
@@ -149,13 +149,43 @@
|
||||
.proto-root .session-chat { border-radius: 12px; }
|
||||
/* 窄屏(≤1380 视口):收窄三栏配比,保证对话区不被裁切(拖拽值 --jc-chat-w 仍优先) */
|
||||
@media (max-width: 1380px) {
|
||||
.proto-root .workspace { --jc-ws-pr: 12px; --jcai-ai-chat-width: var(--jc-chat-w, clamp(430px, 34vw, 560px)); grid-template-columns: 256px minmax(0, 1fr) var(--jcai-ai-chat-width); gap: 10px; padding: 10px 12px; }
|
||||
.proto-root .workspace { --jc-ws-pr: 12px; --jcai-ai-chat-width: var(--jc-chat-w, clamp(430px, 34vw, 560px)); grid-template-columns: 256px minmax(var(--jc-work-min), 1fr) minmax(0, var(--jcai-ai-chat-width)); gap: 10px; padding: 10px 12px; }
|
||||
}
|
||||
|
||||
/* 对话区/视图区可调比例:workspace 直属分隔柄,锚在「视图区↔对话区」的 12px gutter 上
|
||||
(不能放进 .session-chat——其 overflow:hidden 会裁掉负偏移的柄);拖拽调宽、双击复位 */
|
||||
/* 柄贴对话区左缘(跨边 -1/+5px),gutter 左半留给视图区自绘 thumb,两者不抢点击 */
|
||||
.proto-root .jc-vsplit { position: absolute; top: 12px; bottom: 12px; width: 6px; right: calc(var(--jcai-ai-chat-width, 440px) + var(--jc-ws-pr, 14px) - 5px); z-index: 7; cursor: col-resize; display: grid; place-items: center; touch-action: none; }
|
||||
/*
|
||||
* 柄锚在**对话区那一列的真实左缘**,不按 `--jcai-ai-chat-width` 算。
|
||||
*
|
||||
* 原来是 `position:absolute; right: calc(var(--jcai-ai-chat-width) + …)` ——
|
||||
* 那要求对话区的实际宽度恰好等于变量值。自从对话区改成 `minmax(0, var(…))`
|
||||
* (为了给工作区让出地板),它可以比变量窄,柄就按老位置停在原地、飘进工作区。
|
||||
*
|
||||
* 改用栅格定位:`grid-column: -2 / -1` 指的是**最后一列**,列数变了也跟得上
|
||||
* (清单收起后只剩两列,-2 仍然是对话区那列)。柄与对话区重叠放在同一格,
|
||||
* `justify-self: start` + 负外边距让它跨在缝上 —— 缝在哪它就在哪。
|
||||
*/
|
||||
/* 对话区与柄**钉在同一格**:柄显式占了最后一列,对话区若还走自动排位会被顶到第二行 */
|
||||
.proto-root .workspace > .session-chat {
|
||||
grid-column: -2 / -1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.proto-root .jc-vsplit {
|
||||
position: relative;
|
||||
grid-column: -2 / -1;
|
||||
grid-row: 1;
|
||||
justify-self: start;
|
||||
align-self: stretch;
|
||||
margin: 12px 0 12px -5px;
|
||||
width: 6px;
|
||||
z-index: 7;
|
||||
cursor: col-resize;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
touch-action: none;
|
||||
}
|
||||
.proto-root .jc-vsplit::before { content: ""; width: 3px; height: 44px; border-radius: 999px; background: var(--ink-4); opacity: 0; transition: opacity var(--t-fast), background var(--t-fast), height var(--t-fast); }
|
||||
.proto-root .jc-vsplit:hover::before { opacity: .55; }
|
||||
.proto-root .jc-vsplit.dragging::before { opacity: 1; height: 64px; background: var(--brand); }
|
||||
@@ -2765,3 +2795,130 @@
|
||||
.proto-root .jc-stars.ro .jc-star:hover { transform: none; }
|
||||
.proto-root .jc-stars-txt { margin-left: 8px; font-style: normal; font-size: 11px; font-weight: 700; color: var(--ink-3); }
|
||||
.proto-root .jc-rate-note { flex-basis: 100%; font-size: 11.5px; color: var(--ink-2); line-height: 1.6; }
|
||||
|
||||
/*
|
||||
* 视图区容器 —— 活动推送 HTML 的沙箱画框(AM 07)。
|
||||
* 这是**平台本体**的件:活动只给内容,尺寸与边框归系统。
|
||||
* iframe 无脚本执行权(sandbox=""),因此内容自带样式、不继承工作台 CSS。
|
||||
*/
|
||||
.proto-root .jc-view-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
/* 高度由 ViewContainer 按内容量出后写在 style 上 —— 这里不设 height/min-height:
|
||||
定高会把长报告截断,人得在内外两层滚动条之间来回找 */
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ═══ 本仓补充(非原型) ═══════════════════════════════════════════════
|
||||
* 页签条容纳第五个页签「沟通记录」。
|
||||
*
|
||||
* 原型的 `.jc-tnav` 是按**四个**页签配的。加到五个后:
|
||||
* 五个四字页签 380px + 翻页囊 112px + 间距 30px = 522px,而工作区只有 429px。
|
||||
* 差 94px —— 这不是调调 padding 能抹平的,是真的装不下。
|
||||
*
|
||||
* 在定下最终形态之前,先保证**不出卡片**:整条可横滚。
|
||||
* 相比原来「翻页囊被裁到卡片外」,这至少是所有东西都在、都够得着。
|
||||
* 备选形态(待定):缩短页签文字 / 翻页囊只留箭头 / 换行两排 —— 各有取舍,见 debt。
|
||||
*/
|
||||
.proto-root .jc-tnav {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.proto-root .jc-tnav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 一律不压缩:压了就是截字,截了还不如让人横滚 */
|
||||
.proto-root .jc-tnav-tab,
|
||||
.proto-root .jc-tnav-pg,
|
||||
.proto-root .jc-tnav-btn {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* ═══ 本仓补充(非原型) ═══════════════════════════════════════════════
|
||||
* 工作区最小宽度 —— 保证页签条一排放得下。
|
||||
*
|
||||
* 页签加到五个后,页签条含图标的自然内容宽 649px,加上工作卡左右内边距 38px,
|
||||
* 工作区至少要 **687px**。
|
||||
*
|
||||
* 注意别取 570:原型在容器 ≤520px 时会把页签图标藏起来省宽,
|
||||
* 570 恰好落在「图标已经显示、却又放不下」的夹缝里 —— 要么低于 520 让它藏,
|
||||
* 要么给够 687 让它显。这里选后者:图标该在就在,让对话区让位。
|
||||
*
|
||||
* 原来中栏是 `minmax(0, 1fr)`、对话区是定宽轨:空间一紧,**塌的是中栏**,
|
||||
* 页签条就被挤到卡片外裁掉。现在反过来:
|
||||
* · 中栏 `minmax(var(--jc-work-min), 1fr)` —— 有地板,不许再塌
|
||||
* · 对话区 `minmax(0, ...)` —— 由它先让,让到多少算多少
|
||||
* 页签、翻页囊的尺寸一律不动,让的是对话区的空间。
|
||||
*
|
||||
* 690 = 687 上取整留 3px。三种 MacBook Air 都装得下:
|
||||
* 1440(Air 13" M1) → 对话区还剩 384px
|
||||
* 1470(Air 13" M2/M3) → 剩 414px
|
||||
* 1710(Air 15") → 剩 654px
|
||||
* 均高于对话区自身下限 340px。
|
||||
*/
|
||||
.proto-root .workspace {
|
||||
--jc-work-min: 690px;
|
||||
}
|
||||
|
||||
/* 收起清单 / 收起对话时中栏本就宽,地板不必再管 —— 免得收起态反被地板顶出横向溢出 */
|
||||
.proto-root .workspace.jc-side-collapsed,
|
||||
.proto-root .workspace.jc-chat-collapsed {
|
||||
--jc-work-min: 0px;
|
||||
}
|
||||
|
||||
/* 列表区空态 —— 紧凑版:软色圆 + 主副文,与主面板的整屏光环同一语气 */
|
||||
.proto-root .jc-side-empty {
|
||||
margin: 16px 10px;
|
||||
padding: 26px 16px 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #fbfcfe, #f6f8fb);
|
||||
}
|
||||
|
||||
.proto-root .jc-side-empty-ico {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
margin-bottom: 3px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(120% 120% at 30% 20%, #eef3ff, #e3ebff);
|
||||
color: var(--brand);
|
||||
box-shadow: inset 0 0 0 1px rgb(51 112 255 / 14%);
|
||||
}
|
||||
|
||||
.proto-root .jc-side-empty > b {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
|
||||
.proto-root .jc-side-empty > p {
|
||||
margin: 0;
|
||||
max-width: 210px;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.65;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
/*
|
||||
* 页签图标与文字的光学对齐。
|
||||
* 几何上图标正居中,但文字的**字面**(汉字 ink box)整体偏上约 2px ——
|
||||
* Inter 的汉字回退字形上伸高、下沉浅,行盒居中后底边比图标高。
|
||||
* 逐 0.5px 实测扫描:-2px 时两者字面中心差 0.02px(字面高度相近,居中即底齐)。
|
||||
*/
|
||||
.proto-root .jc-tnav-tab svg,
|
||||
.proto-root .jc-tnav-btn.back svg {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 登录页样式(ADR-010)—— 两份原型都没有这一屏,是新造的。
|
||||
*
|
||||
* **自带全套,不蹭原型的类。**
|
||||
* 原型补充样式 `app.css` 通篇以 `.proto-root` 起头,治理台更是压根不加载它;
|
||||
* 登录页在两个台面之前、两份皮肤之外,蹭谁的类都会在另一边落空(实测:表单塌成一行)。
|
||||
* 所以这里把用到的每一条都写全,全部作用域在 `.jc-login` 内,不留任何全局规则。
|
||||
* 视觉口径(蓝 #3370ff、圆角、字号)与原型一致,是照着抄的值,不是引用它的类。
|
||||
*/
|
||||
.jc-login {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #f0f2f5;
|
||||
color: #172033;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.jc-login *,
|
||||
.jc-login *::before,
|
||||
.jc-login *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.jc-login-card {
|
||||
width: 100%;
|
||||
max-width: 352px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
padding: 34px 30px 30px;
|
||||
border: 1px solid #e3e6eb;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 32px rgb(23 32 51 / 9%);
|
||||
}
|
||||
|
||||
/* 品牌头:圆角方章 + 标题 + 副题 */
|
||||
.jc-login-mark {
|
||||
align-self: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 13px;
|
||||
background: linear-gradient(135deg, #3370ff, #2454d6);
|
||||
color: #fff;
|
||||
box-shadow: 0 6px 16px rgb(51 112 255 / 32%);
|
||||
}
|
||||
|
||||
.jc-login-title {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.jc-login-sub {
|
||||
margin: -10px 0 2px;
|
||||
text-align: center;
|
||||
font-size: 12.5px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* 表单:一行一项,标签在上 */
|
||||
.jc-login-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.jc-login-field > span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.jc-login-field > input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid #dfe3e9;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.jc-login-field > input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.jc-login-field > input:focus {
|
||||
border-color: #3370ff;
|
||||
box-shadow: 0 0 0 3px rgb(51 112 255 / 14%);
|
||||
}
|
||||
|
||||
/* 报错条 */
|
||||
.jc-login-err {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid #fbd5da;
|
||||
border-radius: 8px;
|
||||
background: #fff1f2;
|
||||
color: #be123c;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.jc-login-err > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.jc-login-go {
|
||||
height: 38px;
|
||||
border: 1px solid #3370ff;
|
||||
border-radius: 9px;
|
||||
background: #3370ff;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.jc-login-go:hover:not(:disabled) {
|
||||
background: #2861ea;
|
||||
border-color: #2861ea;
|
||||
}
|
||||
|
||||
.jc-login-go:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 种子提示:告诉人初始口令由哪个环境变量定,**不写口令本身** */
|
||||
.jc-login-hint {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 11.5px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
@@ -59,18 +59,3 @@ body {
|
||||
outline-offset: 2px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 视图区容器 —— 活动推送 HTML 的沙箱画框(AM 07)。
|
||||
* 这是**平台本体**的件:活动只给内容,尺寸与边框归系统。
|
||||
* iframe 无脚本执行权(sandbox=""),因此内容自带样式、不继承工作台 CSS。
|
||||
*/
|
||||
.jc-view-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 网页标签的名与章 —— 两台面各有一套,多标签并开时一眼可辨。
|
||||
*
|
||||
* 章随台面的**皮肤基调**走,不另造视觉:
|
||||
* 工作台 = 品牌蓝渐变圆角方 + 白色机器人(与左轨品牌章、登录页方章同源);
|
||||
* 治理中心 = 深色底(治理台顶栏的那种深)+ 盾形勾(管的是规则与放行)。
|
||||
* SVG 以 data: URI 内联 —— 不添静态资产,构建零配置。
|
||||
*/
|
||||
|
||||
const DESK_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#3d7bff"/><stop offset="1" stop-color="#2454d6"/>
|
||||
</linearGradient></defs>
|
||||
<rect width="32" height="32" rx="8" fill="url(#g)"/>
|
||||
<g transform="translate(4,5)">
|
||||
<circle cx="12" cy="1.5" r="1.3" fill="#fff"/>
|
||||
<rect x="11.4" y="2.3" width="1.2" height="2.2" rx="0.6" fill="#fff"/>
|
||||
<rect x="3.6" y="4.6" width="16.8" height="13" rx="4.6" fill="#fff"/>
|
||||
<rect x="1.2" y="8.8" width="2.2" height="4.8" rx="1.1" fill="#fff"/>
|
||||
<rect x="20.6" y="8.8" width="2.2" height="4.8" rx="1.1" fill="#fff"/>
|
||||
<circle cx="9.2" cy="10.4" r="1.9" fill="#2454d6"/>
|
||||
<circle cx="14.8" cy="10.4" r="1.9" fill="#2454d6"/>
|
||||
<path d="M9.3 13.8c.7.9 1.7 1.35 2.7 1.35s2-.45 2.7-1.35" stroke="#2454d6" stroke-width="1.3" fill="none" stroke-linecap="round" opacity="0.55"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
const GOV_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#141b2e"/>
|
||||
<g transform="translate(4,4)" fill="none" stroke="#8fb0ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>
|
||||
<path d="m9 12 2 2 4-4" stroke="#fff"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
|
||||
/** 进哪台就挂哪套 —— 登录页也按 URL 归台,标签在登录前就叫对名字 */
|
||||
export function applyTabIdentity(isGovernance: boolean): void {
|
||||
document.title = isGovernance ? 'JCAI 治理中心' : 'JCAI 工作台';
|
||||
|
||||
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (link === null) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.type = 'image/svg+xml';
|
||||
link.href = 'data:image/svg+xml;utf8,' + encodeURIComponent(isGovernance ? GOV_ICON : DESK_ICON);
|
||||
}
|
||||
Reference in New Issue
Block a user