基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-27 20:43:35 +08:00
co-authored by Claude Fable 5
parent 61fe181678
commit ac85ecf2ea
119 changed files with 16634 additions and 1246 deletions
+32 -25
View File
@@ -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' ? (
+18
View File
@@ -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
View File
@@ -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>
);
}
+160 -3
View File
@@ -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);
}
+151
View File
@@ -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;
}
-15
View File
@@ -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;
}
+47
View File
@@ -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);
}