Commit 1f155f90 authored by David Yang's avatar David Yang

feat: 迁移廉洁风险防控到通用工作台

parent c1d89af9
/node_modules /node_modules
.DS_Store .DS_Store
.pnpm-debug.log
/.env.local /.env.local
/.umirc.local.ts /.umirc.local.ts
/config/config.local.ts /config/config.local.ts
......
...@@ -7,17 +7,19 @@ function resolvePackageRoot(packageName: string) { ...@@ -7,17 +7,19 @@ function resolvePackageRoot(packageName: string) {
const ROUTE_PATHS = { const ROUTE_PATHS = {
login: '/login', login: '/login',
processRiskTable: '/risk_prevention_control_measures_table', processTable: '/risk_prevention_control_measures_table',
displayRiskTable: '/risk_prevention_control_measures_table_show', showTable: '/risk_prevention_control_measures_table_show',
measureDatabase: '/measure_description', measureDescription: '/measure_description',
riskArchive: '/integrity_risk_control_archive', archive: '/integrity_risk_control_archive',
controlMeasureForm: '/control_measure_form',
} as const; } as const;
const APP_BASE_PATH = '/integrity_risk_prevention_new/';
export default defineConfig({ export default defineConfig({
layout: false, layout: false,
fastRefresh: true, fastRefresh: true,
base: '/integrity_risk_prevention/', base: APP_BASE_PATH,
publicPath: '/integrity_risk_prevention/', publicPath: APP_BASE_PATH,
alias: { alias: {
react: resolvePackageRoot('react'), react: resolvePackageRoot('react'),
'react-dom': resolvePackageRoot('react-dom'), 'react-dom': resolvePackageRoot('react-dom'),
...@@ -29,35 +31,35 @@ export default defineConfig({ ...@@ -29,35 +31,35 @@ export default defineConfig({
component: './Login', component: './Login',
}, },
{ {
path: '/control_measure_form', path: ROUTE_PATHS.controlMeasureForm,
layout: false, layout: false,
wrappers: ['@/wrapper/Site', '@/wrapper/Auth'], wrappers: ['@/wrapper/Site', '@/wrapper/Auth'],
component: './integrity_risk/ControlMeasureWorkflow', component: './integrity_risk_prevention/ControlMeasureFormPage',
}, },
{ {
path: '/', path: '/',
wrappers: ['@/wrapper/Site', '@/wrapper/Auth'], wrappers: ['@/wrapper/Site', '@/wrapper/Auth'],
component: './project_manager_xy/Layout', component: './integrity_risk_prevention/Layout',
routes: [ routes: [
{ {
path: '/', path: '/',
redirect: ROUTE_PATHS.processRiskTable, redirect: ROUTE_PATHS.processTable,
}, },
{ {
path: ROUTE_PATHS.processRiskTable, path: ROUTE_PATHS.processTable,
component: './integrity_risk/ProcessRiskTable', component: './integrity_risk_prevention/RiskPreventionTablePage',
}, },
{ {
path: ROUTE_PATHS.displayRiskTable, path: ROUTE_PATHS.showTable,
component: './integrity_risk/DisplayRiskTable', component: './integrity_risk_prevention/RiskPreventionShowTablePage',
}, },
{ {
path: ROUTE_PATHS.measureDatabase, path: ROUTE_PATHS.measureDescription,
component: './integrity_risk/MeasureDatabase', component: './integrity_risk_prevention/MeasureDescriptionPage',
}, },
{ {
path: ROUTE_PATHS.riskArchive, path: ROUTE_PATHS.archive,
component: './integrity_risk/RiskArchive', component: './integrity_risk_prevention/IntegrityRiskArchivePage',
}, },
], ],
}, },
...@@ -89,6 +91,6 @@ export default defineConfig({ ...@@ -89,6 +91,6 @@ export default defineConfig({
define: { define: {
STREAMS_API_URL: '/api', STREAMS_API_URL: '/api',
STREAMS_PARSER_URL: '/parser', STREAMS_PARSER_URL: '/parser',
APP_BASE_PATH: '/integrity_risk_prevention', INTEGRITY_RISK_PREVENTION_APP_BASE_PATH: APP_BASE_PATH,
}, },
}); });
...@@ -104,9 +104,8 @@ AI 在编写 API 调用或数据转换逻辑时,必须遵守: ...@@ -104,9 +104,8 @@ AI 在编写 API 调用或数据转换逻辑时,必须遵守:
## 业务文档索引 ## 业务文档索引
- 审计材料中心模块 - 请阅读 `/src/pages/project_manager_xy/audit-material-center.business-flow.md` - 项目管理模块 - 请阅读 `/src/pages/project_manager_xy/project-management.business-flow.md`
- 模板来源说明 - 如需确认旧项目上下文,可参考 `/src/pages/project_manager_xy/project-management.business-flow.md` - 廉洁风险防控模块 - 请阅读 `/src/pages/integrity_risk_prevention/integrity-risk-prevention.business-flow.md`
- 廉洁风险防控模块 - 请阅读 `/src/pages/integrity_risk/integrity-risk-prevention.business-flow.md`
## 获取当前登录用户信息 ## 获取当前登录用户信息
......
{ {
"name": "integrity_risk_prevention_new", "name": "integrity_risk_prevention",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "integrity_risk_prevention",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.10", "@headlessui/react": "^2.2.10",
...@@ -13,9 +14,10 @@ ...@@ -13,9 +14,10 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.1.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.6", "echarts-for-react": "^3.0.6",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"motion": "^12.23.24",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tailwindcss": "^4.2.0", "tailwindcss": "^4.2.0",
...@@ -4184,12 +4186,12 @@ ...@@ -4184,12 +4186,12 @@
} }
}, },
"node_modules/@tanstack/react-virtual": { "node_modules/@tanstack/react-virtual": {
"version": "3.13.24", "version": "3.13.25",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.25.tgz",
"integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", "integrity": "sha512-bmNoqMu6gcAW9JGrKVB0Q1tN1i5RONZF8r1fW0bbE4Oyf3DwEGnzzQJ2OW+Ozg1P4s8PyugkHg2ULZoFQN+cqw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/virtual-core": "3.14.0" "@tanstack/virtual-core": "3.15.0"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
...@@ -4201,9 +4203,9 @@ ...@@ -4201,9 +4203,9 @@
} }
}, },
"node_modules/@tanstack/virtual-core": { "node_modules/@tanstack/virtual-core": {
"version": "3.14.0", "version": "3.15.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.15.0.tgz",
"integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", "integrity": "sha512-0AwPGx0I8QxPYjAxShT/+z+ZOe9u8mW5rsXvivCTjRfRmz9a43+3mRyi4wwlyoUqOC56q/jatKa0Bh9M99BEHQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
...@@ -10278,13 +10280,13 @@ ...@@ -10278,13 +10280,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/echarts": { "node_modules/echarts": {
"version": "6.1.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"tslib": "2.3.0", "tslib": "2.3.0",
"zrender": "6.1.0" "zrender": "6.0.0"
} }
}, },
"node_modules/echarts-for-react": { "node_modules/echarts-for-react": {
...@@ -11658,6 +11660,33 @@ ...@@ -11658,6 +11660,33 @@
"url": "https://github.com/sponsors/rawify" "url": "https://github.com/sponsors/rawify"
} }
}, },
"node_modules/framer-motion": {
"version": "12.43.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz",
"integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.43.0",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": { "node_modules/fresh": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
...@@ -14586,6 +14615,47 @@ ...@@ -14586,6 +14615,47 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/motion": {
"version": "12.43.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.43.0.tgz",
"integrity": "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.43.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.43.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz",
"integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.39.0"
}
},
"node_modules/motion-utils": {
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
...@@ -21369,9 +21439,9 @@ ...@@ -21369,9 +21439,9 @@
} }
}, },
"node_modules/zrender": { "node_modules/zrender": {
"version": "6.1.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"tslib": "2.3.0" "tslib": "2.3.0"
......
{ {
"name": "integrity_risk_prevention",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "max build", "build": "max build",
...@@ -15,9 +16,10 @@ ...@@ -15,9 +16,10 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
"echarts": "^6.1.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.6", "echarts-for-react": "^3.0.6",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"motion": "^12.23.24",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tailwindcss": "^4.2.0", "tailwindcss": "^4.2.0",
......
This diff is collapsed.
allowBuilds:
core-js: false
core-js-pure: false
es5-ext: false
esbuild: false
{ {
"name": "audit_material_manager", "name": "problem_rectification_new",
"version": "1.0.2", "version": "1.0.6",
"build": "abc", "build": "abc",
"description": "merge master", "description": "merge master",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
......
This diff is collapsed.
...@@ -4,7 +4,7 @@ import { defineApp } from 'umi'; ...@@ -4,7 +4,7 @@ import { defineApp } from 'umi';
export async function getInitialState() { export async function getInitialState() {
return { return {
name: 'project_manager_xy', name: 'integrity_risk_prevention',
}; };
} }
......
...@@ -43,7 +43,7 @@ export const AvatarButton = forwardRef(function AvatarButton( ...@@ -43,7 +43,7 @@ export const AvatarButton = forwardRef(function AvatarButton(
let classes = clsx( let classes = clsx(
className, className,
square ? 'rounded-[20%]' : 'rounded-full', square ? 'rounded-[20%]' : 'rounded-full',
'relative inline-grid focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-[var(--pm-brand)]' 'relative inline-grid focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
) )
return typeof props.href === 'string' ? ( return typeof props.href === 'string' ? (
......
...@@ -54,7 +54,7 @@ export const BadgeButton = forwardRef(function BadgeButton( ...@@ -54,7 +54,7 @@ export const BadgeButton = forwardRef(function BadgeButton(
) { ) {
let classes = clsx( let classes = clsx(
className, className,
'group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-[var(--pm-brand)]' 'group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
) )
return typeof props.href === 'string' ? ( return typeof props.href === 'string' ? (
......
...@@ -57,7 +57,7 @@ const base = [ ...@@ -57,7 +57,7 @@ const base = [
'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]', 'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
, ,
// Focus ring // Focus ring
'group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-[var(--pm-brand)]', 'group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
// Disabled state // Disabled state
'group-data-disabled:opacity-50', 'group-data-disabled:opacity-50',
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--checkbox-check:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent', 'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--checkbox-check:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
......
...@@ -38,7 +38,7 @@ export function Combobox({ ...@@ -38,7 +38,7 @@ export function Combobox({
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
, ,
// Focus ring // Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-[var(--pm-brand)]', 'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
// Disabled state // Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none', 'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
// Invalid state // Invalid state
...@@ -133,7 +133,7 @@ export function ComboboxOption({ children, className, ...props }) { ...@@ -133,7 +133,7 @@ export function ComboboxOption({ children, className, ...props }) {
// Typography // Typography
'text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]', 'text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]',
// Focus // Focus
'outline-hidden data-focus:bg-[var(--pm-brand)] data-focus:text-white', 'outline-hidden data-focus:bg-blue-500 data-focus:text-white',
// Forced colors mode // Forced colors mode
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]', 'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
// Disabled // Disabled
......
...@@ -51,7 +51,7 @@ export function DropdownItem({ className, ...props }) { ...@@ -51,7 +51,7 @@ export function DropdownItem({ className, ...props }) {
// Text styles // Text styles
'text-left text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]', 'text-left text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]',
// Focus // Focus
'data-focus:bg-[var(--pm-brand)] data-focus:text-white', 'data-focus:bg-blue-500 data-focus:text-white',
// Disabled state // Disabled state
'data-disabled:opacity-50', 'data-disabled:opacity-50',
// Forced colors mode // Forced colors mode
......
...@@ -38,7 +38,7 @@ export const Input = forwardRef(function Input( ...@@ -38,7 +38,7 @@ export const Input = forwardRef(function Input(
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
, ,
// Focus ring // Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-[var(--pm-brand)]', 'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
// Disabled state // Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none', 'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])} ])}
......
...@@ -8,11 +8,12 @@ ...@@ -8,11 +8,12 @@
import * as Headless from '@headlessui/react' import * as Headless from '@headlessui/react'
import React, { forwardRef } from 'react' import React, { forwardRef } from 'react'
import { Link as UmiLink } from 'umi'
export const Link = forwardRef(function Link(props, ref) { export const Link = forwardRef(function Link({ href, ...props }, ref) {
return ( return (
<Headless.DataInteractive> <Headless.DataInteractive>
<a {...props} ref={ref} /> <UmiLink {...props} to={href} ref={ref} />
</Headless.DataInteractive> </Headless.DataInteractive>
) )
}) })
...@@ -22,7 +22,7 @@ export function Listbox({ className, placeholder, autoFocus, 'aria-label': ariaL ...@@ -22,7 +22,7 @@ export function Listbox({ className, placeholder, autoFocus, 'aria-label': ariaL
// Hide default focus styles // Hide default focus styles
'focus:outline-hidden', 'focus:outline-hidden',
// Focus ring // Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset data-focus:after:ring-2 data-focus:after:ring-[var(--pm-brand)]', 'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset data-focus:after:ring-2 data-focus:after:ring-blue-500',
// Disabled state // Disabled state
'data-disabled:opacity-50 data-disabled:before:bg-zinc-950/5 data-disabled:before:shadow-none', 'data-disabled:opacity-50 data-disabled:before:bg-zinc-950/5 data-disabled:before:shadow-none',
])} ])}
...@@ -115,7 +115,7 @@ export function ListboxOption({ children, className, ...props }) { ...@@ -115,7 +115,7 @@ export function ListboxOption({ children, className, ...props }) {
// Typography // Typography
'text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]', 'text-base/6 text-zinc-950 sm:text-sm/6 forced-colors:text-[CanvasText]',
// Focus // Focus
'outline-hidden data-focus:bg-[var(--pm-brand)] data-focus:text-white', 'outline-hidden data-focus:bg-blue-500 data-focus:text-white',
// Forced colors mode // Forced colors mode
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]', 'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
// Disabled // Disabled
......
...@@ -61,7 +61,7 @@ const base = [ ...@@ -61,7 +61,7 @@ const base = [
// Indicator color (dark mode) // Indicator color (dark mode)
, ,
// Focus ring // Focus ring
'group-data-focus:outline group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-[var(--pm-brand)]', 'group-data-focus:outline group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
// Disabled state // Disabled state
'group-data-disabled:opacity-50', 'group-data-disabled:opacity-50',
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--radio-checked-indicator:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent', 'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--radio-checked-indicator:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
......
...@@ -15,7 +15,7 @@ export const Select = forwardRef(function Select({ className, multiple, ...props ...@@ -15,7 +15,7 @@ export const Select = forwardRef(function Select({ className, multiple, ...props
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
, ,
// Focus ring // Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset has-data-focus:after:ring-2 has-data-focus:after:ring-[var(--pm-brand)]', 'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset has-data-focus:after:ring-2 has-data-focus:after:ring-blue-500',
// Disabled state // Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none', 'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])} ])}
......
'use client' 'use client'
import * as Headless from '@headlessui/react' import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { useState } from 'react' import React, { useState } from 'react'
import { NavbarItem } from './navbar' import { NavbarItem } from './navbar'
...@@ -30,6 +31,11 @@ function MobileSidebar({ open, close, children }) { ...@@ -30,6 +31,11 @@ function MobileSidebar({ open, close, children }) {
<Headless.DialogPanel <Headless.DialogPanel
transition transition
className="fixed inset-y-0 w-full max-w-80 p-2 transition duration-300 ease-in-out data-closed:-translate-x-full" className="fixed inset-y-0 w-full max-w-80 p-2 transition duration-300 ease-in-out data-closed:-translate-x-full"
onClick={(event) => {
if (event.target instanceof Element && event.target.closest('a[href]')) {
close()
}
}}
> >
<div className="flex h-full flex-col rounded-lg bg-white shadow-xs ring-1 ring-zinc-950/5"> <div className="flex h-full flex-col rounded-lg bg-white shadow-xs ring-1 ring-zinc-950/5">
<div className="-mb-3 px-4 pt-3"> <div className="-mb-3 px-4 pt-3">
...@@ -44,7 +50,7 @@ function MobileSidebar({ open, close, children }) { ...@@ -44,7 +50,7 @@ function MobileSidebar({ open, close, children }) {
) )
} }
export function SidebarLayout({ navbar, sidebar, children }) { export function SidebarLayout({ navbar, sidebar, children, contentClassName }) {
let [showSidebar, setShowSidebar] = useState(false) let [showSidebar, setShowSidebar] = useState(false)
return ( return (
...@@ -70,7 +76,7 @@ export function SidebarLayout({ navbar, sidebar, children }) { ...@@ -70,7 +76,7 @@ export function SidebarLayout({ navbar, sidebar, children }) {
{/* Content */} {/* Content */}
<main className="flex flex-1 flex-col pb-2 lg:min-w-0 lg:pt-2 lg:pr-2 lg:pl-64"> <main className="flex flex-1 flex-col pb-2 lg:min-w-0 lg:pt-2 lg:pr-2 lg:pl-64">
<div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5"> <div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5">
<div className="mx-auto max-w-6xl">{children}</div> <div className={clsx('mx-auto w-full max-w-6xl', contentClassName)}>{children}</div>
</div> </div>
</main> </main>
</div> </div>
......
...@@ -4,8 +4,8 @@ import * as Headless from '@headlessui/react' ...@@ -4,8 +4,8 @@ import * as Headless from '@headlessui/react'
import clsx from 'clsx' import clsx from 'clsx'
import { LayoutGroup, motion } from 'motion/react' import { LayoutGroup, motion } from 'motion/react'
import React, { forwardRef, useId } from 'react' import React, { forwardRef, useId } from 'react'
import { Link as UmiLink } from 'umi'
import { TouchTarget } from './button' import { TouchTarget } from './button'
import { Link } from './link'
export function Sidebar({ className, ...props }) { export function Sidebar({ className, ...props }) {
return <nav {...props} className={clsx(className, 'flex h-full min-h-0 flex-col')} /> return <nav {...props} className={clsx(className, 'flex h-full min-h-0 flex-col')} />
...@@ -67,7 +67,7 @@ export function SidebarSpacer({ className, ...props }) { ...@@ -67,7 +67,7 @@ export function SidebarSpacer({ className, ...props }) {
export function SidebarHeading({ className, ...props }) { export function SidebarHeading({ className, ...props }) {
return ( return (
<h3 {...props} className={clsx(className, 'mb-1 px-2 text-xs/6 font-medium text-zinc-500')} /> <h3 {...props} className={clsx(className, 'mb-1 px-2 text-xs/6 font-medium text-zinc-500 dark:text-zinc-400')} />
) )
} }
...@@ -76,6 +76,10 @@ export const SidebarItem = forwardRef(function SidebarItem( ...@@ -76,6 +76,10 @@ export const SidebarItem = forwardRef(function SidebarItem(
ref ref
) { ) {
let href = props.href
let interactiveProps = { ...props }
delete interactiveProps.href
let classes = clsx( let classes = clsx(
// Base // Base
'flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-base/6 font-medium text-zinc-950 sm:py-2 sm:text-sm/5', 'flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-base/6 font-medium text-zinc-950 sm:py-2 sm:text-sm/5',
...@@ -86,16 +90,16 @@ export const SidebarItem = forwardRef(function SidebarItem( ...@@ -86,16 +90,16 @@ export const SidebarItem = forwardRef(function SidebarItem(
// Avatar // Avatar
'*:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 sm:*:data-[slot=avatar]:size-6', '*:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 sm:*:data-[slot=avatar]:size-6',
// Hover // Hover
'data-hover:bg-zinc-950/5 data-hover:*:data-[slot=icon]:fill-zinc-950', 'hover:bg-zinc-950/5 hover:*:data-[slot=icon]:fill-zinc-950',
// Active // Active
'data-active:bg-zinc-950/5 data-active:*:data-[slot=icon]:fill-zinc-950', 'active:bg-zinc-950/5 active:*:data-[slot=icon]:fill-zinc-950',
// Current // Current
'data-current:*:data-[slot=icon]:fill-zinc-950', 'data-current:bg-blue-600 data-current:text-white data-current:*:data-[slot=icon]:fill-white',
// Dark mode // Dark mode
, 'dark:text-white dark:*:data-[slot=icon]:fill-zinc-400',
, 'dark:hover:bg-white/5 dark:hover:*:data-[slot=icon]:fill-white',
, 'dark:active:bg-white/5 dark:active:*:data-[slot=icon]:fill-white',
'dark:data-current:bg-blue-600 dark:data-current:text-white dark:data-current:*:data-[slot=icon]:fill-white'
) )
return ( return (
...@@ -103,19 +107,19 @@ export const SidebarItem = forwardRef(function SidebarItem( ...@@ -103,19 +107,19 @@ export const SidebarItem = forwardRef(function SidebarItem(
{current && ( {current && (
<motion.span <motion.span
layoutId="current-indicator" layoutId="current-indicator"
className="absolute inset-y-2 -left-4 w-0.5 rounded-full bg-zinc-950" className="absolute inset-y-2 -left-4 w-0.5 rounded-full bg-zinc-950 dark:bg-white"
/> />
)} )}
{typeof props.href === 'string' ? ( {typeof href === 'string' ? (
<Headless.CloseButton <UmiLink
as={Link} {...interactiveProps}
{...props} to={href}
className={classes} className={classes}
data-current={current ? 'true' : undefined} data-current={current ? 'true' : undefined}
ref={ref} ref={ref}
> >
<TouchTarget>{children}</TouchTarget> <TouchTarget>{children}</TouchTarget>
</Headless.CloseButton> </UmiLink>
) : ( ) : (
<Headless.Button <Headless.Button
{...props} {...props}
......
...@@ -148,7 +148,7 @@ export function Switch({ color = 'dark/zinc', className, ...props }) { ...@@ -148,7 +148,7 @@ export function Switch({ color = 'dark/zinc', className, ...props }) {
// Checked // Checked
'data-checked:bg-(--switch-bg) data-checked:ring-(--switch-bg-ring)', 'data-checked:bg-(--switch-bg) data-checked:ring-(--switch-bg-ring)',
// Focus // Focus
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-[var(--pm-brand)]', 'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
// Hover // Hover
'data-hover:ring-black/15 data-hover:data-checked:ring-(--switch-bg-ring)', 'data-hover:ring-black/15 data-hover:data-checked:ring-(--switch-bg-ring)',
, ,
......
...@@ -16,6 +16,7 @@ export function Table({ ...@@ -16,6 +16,7 @@ export function Table({
dense = false, dense = false,
grid = false, grid = false,
striped = false, striped = false,
scrollable = true,
className, className,
children, children,
...props ...props
...@@ -27,7 +28,9 @@ export function Table({ ...@@ -27,7 +28,9 @@ export function Table({
{...props} {...props}
className={clsx( className={clsx(
className, className,
'-mx-(--gutter) overflow-x-auto whitespace-nowrap', '-mx-(--gutter)',
scrollable && 'overflow-x-auto',
'whitespace-nowrap',
)} )}
> >
<div <div
...@@ -70,7 +73,7 @@ export function TableRow({ href, target, title, className, ...props }) { ...@@ -70,7 +73,7 @@ export function TableRow({ href, target, title, className, ...props }) {
className={clsx( className={clsx(
className, className,
href && href &&
'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-[var(--pm-brand)]', 'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-blue-500',
striped && 'even:bg-zinc-950/2.5', striped && 'even:bg-zinc-950/2.5',
href && striped && 'hover:bg-zinc-950/5', href && striped && 'hover:bg-zinc-950/5',
href && !striped && 'hover:bg-zinc-950/2.5', href && !striped && 'hover:bg-zinc-950/2.5',
......
...@@ -15,7 +15,7 @@ export const Textarea = forwardRef(function Textarea({ className, resizable = tr ...@@ -15,7 +15,7 @@ export const Textarea = forwardRef(function Textarea({ className, resizable = tr
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo // Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
, ,
// Focus ring // Focus ring
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-[var(--pm-brand)]', 'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
// Disabled state // Disabled state
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none', 'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
])} ])}
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import * as Headless from '@headlessui/react';
import {
CheckIcon,
ChevronUpDownIcon,
MagnifyingGlassIcon,
XMarkIcon,
} from '@heroicons/react/20/solid';
import clsx from 'clsx';
import { useMemo, useState } from 'react';
function serializeValue(value) {
if (Array.isArray(value)) {
return `[${value.map(serializeValue).join(',')}]`;
}
if (value && typeof value === 'object') {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${serializeValue(value[key])}`)
.join(',')}}`;
}
return JSON.stringify(value);
}
function getValueKey(value) {
try {
return `${typeof value}:${serializeValue(value)}`;
} catch (error) {
return `${typeof value}:${String(value)}`;
}
}
function getValueLabel(value) {
if (value && typeof value === 'object') {
return String(
value.label
|| value.title
|| value.name
|| value.display_name
|| value._id
|| serializeValue(value),
);
}
return String(value ?? '');
}
export default function MultiSelect({
value,
options = [],
onChange,
disabled = false,
placeholder = '请选择或搜索',
ariaLabel = '多选',
}) {
const [query, setQuery] = useState('');
const normalizedOptions = useMemo(() => options.map((option, index) => {
const optionValue = option?.value;
const valueKey = getValueKey(optionValue);
return {
...option,
value: optionValue,
valueKey,
reactKey: `${valueKey}-${index}`,
label: String(option?.label ?? optionValue ?? ''),
disabled: Boolean(option?.disabled),
};
}), [options]);
const optionByValue = useMemo(
() => new Map(normalizedOptions.map((option) => [option.valueKey, option])),
[normalizedOptions],
);
const selectedOptions = useMemo(() => {
const selectedValues = Array.isArray(value)
? value
: value === null || value === undefined || value === ''
? []
: [value];
return selectedValues.map((selectedValue, index) => {
const valueKey = getValueKey(selectedValue);
return optionByValue.get(valueKey) || {
value: selectedValue,
valueKey,
reactKey: `selected-${valueKey}-${index}`,
label: getValueLabel(selectedValue),
};
});
}, [optionByValue, value]);
const filteredOptions = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
if (!normalizedQuery) {
return normalizedOptions;
}
return normalizedOptions.filter((option) => (
option.label.toLocaleLowerCase().includes(normalizedQuery)
|| String(option.value ?? '').toLocaleLowerCase().includes(normalizedQuery)
));
}, [normalizedOptions, query]);
const updateValue = (nextOptions) => {
onChange(nextOptions.map((option) => option.value));
setQuery('');
};
const removeOption = (optionToRemove) => {
updateValue(selectedOptions.filter(
(option) => option.valueKey !== optionToRemove.valueKey,
));
};
return (
<Headless.Combobox
value={selectedOptions}
onChange={updateValue}
by={(left, right) => left?.valueKey === right?.valueKey}
onClose={() => setQuery('')}
disabled={disabled}
multiple
immediate
>
<div data-slot="control" className="relative w-full">
<div
className={clsx(
'relative min-h-11 w-full rounded-lg border border-zinc-950/10 bg-white shadow-sm transition',
'focus-within:border-[var(--pm-brand)] focus-within:ring-2 focus-within:ring-[color-mix(in_srgb,var(--pm-brand)_18%,transparent)]',
disabled && 'cursor-not-allowed bg-zinc-100 opacity-60 shadow-none',
)}
>
{selectedOptions.length > 0 ? (
<div className="flex max-h-20 min-w-0 flex-wrap items-center gap-1.5 overflow-y-auto border-b border-zinc-100 px-3 py-2">
{selectedOptions.map((option) => (
<span
key={option.reactKey}
className="inline-flex max-w-full items-center gap-1 rounded-md bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-700 ring-1 ring-inset ring-zinc-200"
>
<span className="truncate">{option.label}</span>
<button
type="button"
disabled={disabled}
onClick={() => removeOption(option)}
className="shrink-0 rounded-sm text-zinc-400 outline-none transition hover:bg-zinc-200 hover:text-zinc-700 focus-visible:ring-2 focus-visible:ring-[var(--pm-brand)] disabled:pointer-events-none"
aria-label={`移除${option.label}`}
title={`移除${option.label}`}
>
<XMarkIcon className="size-3.5" aria-hidden="true" />
</button>
</span>
))}
</div>
) : null}
<div className="relative h-10 w-full">
<MagnifyingGlassIcon
className="pointer-events-none absolute left-3 top-1/2 z-10 size-4 -translate-y-1/2 text-zinc-400"
aria-hidden="true"
/>
<Headless.ComboboxInput
value={query}
onChange={(event) => setQuery(event.target.value)}
displayValue={() => query}
placeholder={selectedOptions.length > 0 ? '继续搜索' : placeholder}
aria-label={ariaLabel}
className="h-10 w-full rounded-lg border-0 bg-transparent py-0 pl-9 pr-20 text-sm text-zinc-950 outline-none placeholder:text-zinc-400 disabled:cursor-not-allowed"
/>
<div className="absolute inset-y-0 right-1.5 flex items-center gap-0.5">
{selectedOptions.length > 0 ? (
<button
type="button"
disabled={disabled}
onClick={() => updateValue([])}
className="rounded-md p-1.5 text-zinc-400 outline-none transition hover:bg-zinc-100 hover:text-zinc-700 focus-visible:ring-2 focus-visible:ring-[var(--pm-brand)] disabled:pointer-events-none"
aria-label="清空已选项"
title="清空已选项"
>
<XMarkIcon className="size-4" aria-hidden="true" />
</button>
) : null}
<Headless.ComboboxButton
className="rounded-md p-1.5 text-zinc-500 outline-none transition hover:bg-zinc-100 hover:text-zinc-700 focus-visible:ring-2 focus-visible:ring-[var(--pm-brand)] disabled:pointer-events-none"
aria-label="展开选项"
title="展开选项"
>
<ChevronUpDownIcon className="size-4" aria-hidden="true" />
</Headless.ComboboxButton>
</div>
</div>
</div>
<Headless.ComboboxOptions
anchor="bottom start"
transition
className={clsx(
'z-[100] max-h-56! w-[var(--input-width)] overflow-y-auto overscroll-contain rounded-lg border border-zinc-200 bg-white p-1 shadow-xl ring-1 ring-black/5 outline-none',
'[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(4)]',
'transition duration-100 ease-out data-closed:scale-95 data-closed:opacity-0',
)}
>
{filteredOptions.length > 0 ? filteredOptions.map((option) => (
<Headless.ComboboxOption
key={option.reactKey}
value={option}
disabled={option.disabled}
className="group flex cursor-default items-center gap-2.5 rounded-md px-3 py-2 text-sm text-zinc-700 outline-none data-disabled:opacity-45 data-focus:bg-zinc-100 data-focus:text-zinc-950"
>
{({ selected }) => (
<>
<span
className={clsx(
'flex size-4 shrink-0 items-center justify-center rounded border transition',
selected
? 'border-[var(--pm-brand)] bg-[var(--pm-brand)] text-white'
: 'border-zinc-300 bg-white text-transparent group-data-focus:border-zinc-400',
)}
aria-hidden="true"
>
<CheckIcon className="size-3" />
</span>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
</>
)}
</Headless.ComboboxOption>
)) : (
<div className="px-3 py-6 text-center text-sm text-zinc-500">
{normalizedOptions.length === 0 ? '暂无可选项' : '未找到匹配选项'}
</div>
)}
{normalizedOptions.length > 0 ? (
<div className="sticky bottom-0 mt-1 flex items-center justify-between border-t border-zinc-100 bg-white px-3 py-2 text-xs text-zinc-500">
<span>已选择 {selectedOptions.length} 项</span>
{selectedOptions.length > 0 ? (
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => updateValue([])}
className="font-medium text-[var(--pm-brand)] outline-none hover:underline focus-visible:underline"
>
清空
</button>
) : null}
</div>
) : null}
</Headless.ComboboxOptions>
</div>
</Headless.Combobox>
);
}
This diff is collapsed.
import { Checkbox } from '@/catalyst-ui-kit/javascript/checkbox';
import { Input } from '@/catalyst-ui-kit/javascript/input';
import { queryUsers } from '@/services/DataService';
import { normalizeUserLabel, toArray } from '@/utils/subjectView';
import { useEffect, useMemo, useState } from 'react';
export default function UserSearchSelect({ value, onChange, multiple = false }) {
const [keyword, setKeyword] = useState('');
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const selectedIds = useMemo(
() => toArray(value).map((item) => item?._id || item).filter(Boolean),
[value],
);
useEffect(() => {
let cancelled = false;
const loadUsers = async () => {
setLoading(true);
try {
const text = keyword.trim();
const result = await queryUsers(0, 30, text
? {
$or: [
{ username: { $regex: text, $options: 'i' } },
{ display_name: { $regex: text, $options: 'i' } },
],
}
: {});
if (!cancelled) {
setUsers(result?.users || []);
}
} catch (error) {
if (!cancelled) {
setUsers([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
loadUsers();
return () => {
cancelled = true;
};
}, [keyword]);
const toggleUser = (userId, checked) => {
if (!multiple) {
onChange(checked ? userId : null);
return;
}
onChange(
checked
? Array.from(new Set([...selectedIds, userId]))
: selectedIds.filter((item) => item !== userId),
);
};
return (
<div className="space-y-3">
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索用户名或显示名"
/>
<div className="max-h-56 overflow-y-auto rounded-lg border border-zinc-200 bg-zinc-50 p-2">
{loading ? (
<div className="px-3 py-4 text-sm text-zinc-500">正在加载用户...</div>
) : users.length === 0 ? (
<div className="px-3 py-4 text-sm text-zinc-500">暂无可选用户</div>
) : (
<div className="space-y-1">
{users.map((user) => {
const checked = selectedIds.includes(user._id);
return (
<label
key={user._id}
className="flex min-h-11 cursor-pointer items-center gap-3 rounded-md bg-white px-3 py-2 text-sm transition hover:bg-blue-50"
>
<Checkbox
checked={checked}
onChange={(nextChecked) => toggleUser(user._id, nextChecked)}
color="blue"
/>
<span className="min-w-0">
<span className="block truncate font-medium text-zinc-950">
{normalizeUserLabel(user)}
</span>
<span className="block truncate text-xs text-zinc-500">{user.username}</span>
</span>
</label>
);
})}
</div>
)}
</div>
</div>
);
}
This diff is collapsed.
:root { :root {
--pm-brand: #c81e3a; --pm-brand: #b42318;
--pm-brand-strong: #9f1239; --pm-brand-strong: #8f1d14;
--pm-accent: #059669;
--pm-surface: #ffffff; --pm-surface: #ffffff;
--pm-bg: #fff1f2; --pm-bg: #f8fafc;
--pm-sidebar: #0f172a; --pm-sidebar: #18181b;
--pm-sidebar-soft: #1e293b; --pm-sidebar-soft: #27272a;
--pm-text: #0f172a; --pm-text: #0f172a;
--pm-muted: #64748b; --pm-muted: #64748b;
} }
...@@ -16,8 +17,18 @@ body, ...@@ -16,8 +17,18 @@ body,
} }
body { body {
--color-blue-50: #fef2f2;
--color-blue-100: #fee2e2;
--color-blue-200: #fecaca;
--color-blue-300: #fca5a5;
--color-blue-400: #f87171;
--color-blue-500: #dc2626;
--color-blue-600: #b42318;
--color-blue-700: #991b1b;
--color-blue-800: #7f1d1d;
--color-blue-900: #450a0a;
margin: 0; margin: 0;
background: linear-gradient(180deg, #fff7f7 0%, #fff1f2 100%); background: var(--pm-bg);
color: var(--pm-text); color: var(--pm-text);
font-family: font-family:
"PingFang SC", "PingFang SC",
......
import { UserContext } from '@/wrapper/Auth';
import { useContext } from 'react';
export function useUserInfo() {
return useContext(UserContext);
}
export default useUserInfo;
import authService from '@/services/authService'; import authService from '@/services/authService';
import { Button } from '@/catalyst-ui-kit/javascript/button';
import { Input } from '@/catalyst-ui-kit/javascript/input';
import { getCaptcha, userLogin } from '@/services/DataService'; import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import CryptoJS from 'crypto-js'; import CryptoJS from 'crypto-js';
...@@ -26,17 +28,17 @@ export default function LoginPage() { ...@@ -26,17 +28,17 @@ export default function LoginPage() {
const redirect = searchParams.get('redirect') || '/'; const redirect = searchParams.get('redirect') || '/';
const siteTitle = site?.title || site?.name || '廉洁风险防控'; const siteTitle = site?.title || site?.name || '廉洁风险防控系统';
const siteSubtitle = const siteSubtitle =
site?.subtitle || '风险识别、风险评估、防控措施与历史归档统一管理'; site?.subtitle || '风险点识别、措施维护、审批与归档统一协同';
const backgroundStyle = useMemo(() => { const backgroundStyle = useMemo(() => {
const overlay = const overlay =
'linear-gradient(135deg, rgba(28, 10, 16, 0.94) 0%, rgba(98, 20, 38, 0.9) 52%, rgba(190, 24, 93, 0.82) 100%)'; 'linear-gradient(135deg, rgba(9, 30, 66, 0.96) 0%, rgba(30, 64, 175, 0.9) 56%, rgba(5, 150, 105, 0.82) 100%)';
if (!site?.background) { if (!site?.background) {
return { return {
backgroundImage: `${overlay}, radial-gradient(circle at top, #57111f 0%, #21070f 55%, #120308 100%)`, backgroundImage: `${overlay}, linear-gradient(145deg, #0f172a 0%, #1d4ed8 60%, #047857 100%)`,
}; };
} }
...@@ -96,15 +98,15 @@ export default function LoginPage() { ...@@ -96,15 +98,15 @@ export default function LoginPage() {
return ( return (
<div <div
className="min-h-screen bg-[#18060d] px-4 py-6 text-white md:px-8" className="min-h-screen bg-[#061321] px-4 py-6 text-white md:px-8"
style={backgroundStyle} style={backgroundStyle}
> >
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-7xl overflow-hidden rounded-[32px] border border-white/10 bg-white/6 shadow-[0_28px_120px_rgba(0,0,0,0.38)] backdrop-blur-sm"> <div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-7xl overflow-hidden rounded-lg border border-white/10 bg-white/6 shadow-[0_28px_120px_rgba(0,0,0,0.38)] backdrop-blur-sm">
<section className="relative hidden w-[52%] flex-col justify-between overflow-hidden px-10 py-12 lg:flex"> <section className="relative hidden w-[52%] flex-col justify-between overflow-hidden px-10 py-12 lg:flex">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.2),transparent_36%),radial-gradient(circle_at_bottom_right,rgba(244,63,94,0.28),transparent_32%)]" /> <div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.2),transparent_36%),radial-gradient(circle_at_bottom_right,rgba(49,196,255,0.3),transparent_32%)]" />
<div className="relative z-10 max-w-xl"> <div className="relative z-10 max-w-xl">
<p className="text-xs font-medium uppercase tracking-[0.45em] text-white/60"> <p className="text-xs font-medium uppercase text-white/60">
Integrity Risk Prevention Integrity Risk Access
</p> </p>
<h1 className="mt-6 text-4xl font-semibold leading-tight text-white"> <h1 className="mt-6 text-4xl font-semibold leading-tight text-white">
{siteTitle} {siteTitle}
...@@ -115,20 +117,20 @@ export default function LoginPage() { ...@@ -115,20 +117,20 @@ export default function LoginPage() {
</div> </div>
<div className="relative z-10 grid gap-4 xl:grid-cols-2"> <div className="relative z-10 grid gap-4 xl:grid-cols-2">
<div className="rounded-3xl border border-white/10 bg-white/8 p-6"> <div className="rounded-lg border border-white/10 bg-white/8 p-6">
<div className="text-sm text-white/65">协同视图</div> <div className="text-sm text-white/65">防控闭环</div>
<div className="mt-3 text-2xl font-semibold text-white"> <div className="mt-3 text-2xl font-semibold text-white">
风险识别 + 风险评估 填报 + 审核 + 归档
</div> </div>
<p className="mt-3 text-sm leading-6 text-white/68"> <p className="mt-3 text-sm leading-6 text-white/68">
统一维护风险点、涉及环节、岗位人员与风险等级。 风险点推送后依次完成填报、复核和最终审核,驳回后自动回到填报阶段。
</p> </p>
</div> </div>
<div className="rounded-3xl border border-white/10 bg-black/18 p-6"> <div className="rounded-lg border border-white/10 bg-black/18 p-6">
<div className="text-sm text-white/65">状态追踪</div> <div className="text-sm text-white/65">风险台账</div>
<div className="mt-3 text-2xl font-semibold text-white">填报与审批</div> <div className="mt-3 text-2xl font-semibold text-white">过程表与展示表</div>
<p className="mt-3 text-sm leading-6 text-white/68"> <p className="mt-3 text-sm leading-6 text-white/68">
推送填报、审核状态、防控措施和历史快照全程留痕。 统一维护风险点、防控措施和制度依据,并保留每次归档形成的历史快照。
</p> </p>
</div> </div>
</div> </div>
...@@ -137,14 +139,14 @@ export default function LoginPage() { ...@@ -137,14 +139,14 @@ export default function LoginPage() {
<section className="flex flex-1 items-center justify-center bg-white/92 px-6 py-10 text-slate-900 sm:px-10"> <section className="flex flex-1 items-center justify-center bg-white/92 px-6 py-10 text-slate-900 sm:px-10">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="mb-8"> <div className="mb-8">
<p className="text-sm font-medium uppercase tracking-[0.35em] text-rose-600/80"> <p className="text-sm font-medium uppercase text-blue-600/80">
Sign In Sign In
</p> </p>
<h2 className="mt-3 text-3xl font-semibold text-slate-900"> <h2 className="mt-3 text-3xl font-semibold text-slate-900">
登录廉洁风险防控 登录廉洁风险防控系统
</h2> </h2>
<p className="mt-3 text-sm leading-6 text-slate-500"> <p className="mt-3 text-sm leading-6 text-slate-500">
使用统一账号登录后进入廉洁风险防控工作区。 使用现有账号和验证码进入廉洁风险工作台。
</p> </p>
</div> </div>
...@@ -153,8 +155,8 @@ export default function LoginPage() { ...@@ -153,8 +155,8 @@ export default function LoginPage() {
<label className="mb-2 block text-sm font-medium text-slate-700"> <label className="mb-2 block text-sm font-medium text-slate-700">
账号 账号
</label> </label>
<input <Input
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-500 focus:bg-white" className="w-full"
value={identity} value={identity}
onChange={(event) => setIdentity(event.target.value)} onChange={(event) => setIdentity(event.target.value)}
disabled={loading} disabled={loading}
...@@ -167,9 +169,9 @@ export default function LoginPage() { ...@@ -167,9 +169,9 @@ export default function LoginPage() {
<label className="mb-2 block text-sm font-medium text-slate-700"> <label className="mb-2 block text-sm font-medium text-slate-700">
密码 密码
</label> </label>
<input <Input
type="password" type="password"
className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-500 focus:bg-white" className="w-full"
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
disabled={loading} disabled={loading}
...@@ -183,8 +185,8 @@ export default function LoginPage() { ...@@ -183,8 +185,8 @@ export default function LoginPage() {
验证码 验证码
</label> </label>
<div className="flex gap-3"> <div className="flex gap-3">
<input <Input
className="min-w-0 flex-1 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-rose-500 focus:bg-white" className="min-w-0 flex-1"
value={captchaCode} value={captchaCode}
onChange={(event) => setCaptchaCode(event.target.value)} onChange={(event) => setCaptchaCode(event.target.value)}
disabled={loading} disabled={loading}
...@@ -198,7 +200,7 @@ export default function LoginPage() { ...@@ -198,7 +200,7 @@ export default function LoginPage() {
refreshCaptcha(); refreshCaptcha();
}} }}
disabled={loading || captchaLoading} disabled={loading || captchaLoading}
className="flex h-[56px] w-[148px] shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-slate-200 bg-white px-2 text-xs text-slate-500 transition hover:border-rose-300 hover:text-rose-600 disabled:cursor-not-allowed disabled:opacity-70" className="flex h-[44px] w-[148px] shrink-0 items-center justify-center overflow-hidden rounded-lg border border-slate-200 bg-white px-2 text-xs text-slate-500 transition hover:border-blue-300 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-70"
> >
{captchaData?.image ? ( {captchaData?.image ? (
<span <span
...@@ -213,18 +215,19 @@ export default function LoginPage() { ...@@ -213,18 +215,19 @@ export default function LoginPage() {
</div> </div>
{error ? ( {error ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600"> <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
{error} {error}
</div> </div>
) : null} ) : null}
<button <Button
type="submit" type="submit"
color="blue"
disabled={loading || captchaLoading} disabled={loading || captchaLoading}
className="flex w-full items-center justify-center rounded-2xl bg-[linear-gradient(135deg,#9f1239_0%,#be123c_52%,#e11d48_100%)] px-4 py-3 text-sm font-medium text-white shadow-[0_16px_40px_rgba(190,24,93,0.28)] transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-70" className="flex w-full justify-center"
> >
{loading ? '登录中...' : '登录'} {loading ? '登录中...' : '登录'}
</button> </Button>
</form> </form>
</div> </div>
</section> </section>
......
import { useEffect, useMemo, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/catalyst-ui-kit/javascript/table';
import { loadSubject, queryRecords } from '@/services/DataService';
import {
formatFieldValue,
getFieldValue,
getReferenceSubjectName,
normalizeRecordId,
toArray,
} from '@/utils/subjectView';
const TITLE_COLUMN = {
field: 'title',
label: '标题',
fieldDef: { name: 'title', label: '标题', type: 'text' },
};
function hasRecordBody(value) {
return value && typeof value === 'object' && Boolean(value.metadata || value.title || value.name);
}
function getReferenceIds(value) {
return toArray(value).map(normalizeRecordId).filter(Boolean);
}
function inferColumnsFromRecords(records) {
const metadataKeys = [];
const seenKeys = new Set();
records.forEach((record) => {
Object.keys(record?.metadata || {}).forEach((key) => {
if (!seenKeys.has(key)) {
seenKeys.add(key);
metadataKeys.push(key);
}
});
});
const metadataColumns = metadataKeys.map((key) => ({
field: `metadata.${key}`,
label: key,
fieldDef: { name: key, label: key, type: 'text' },
}));
return metadataColumns.length > 0 ? metadataColumns : [TITLE_COLUMN];
}
function buildColumns(subject, records) {
if (!subject?.fields) {
return inferColumnsFromRecords(records);
}
const metadataColumns = toArray(subject?.fields).map((field) => ({
field: `metadata.${field.name}`,
label: field.label || field.name,
fieldDef: field,
}));
return metadataColumns.length > 0 ? metadataColumns : [TITLE_COLUMN];
}
function sortRecordsByReferenceOrder(records, referenceIds) {
const orderMap = new Map(referenceIds.map((id, index) => [id, index]));
return [...records].sort((left, right) => {
const leftOrder = orderMap.get(normalizeRecordId(left)) ?? Number.MAX_SAFE_INTEGER;
const rightOrder = orderMap.get(normalizeRecordId(right)) ?? Number.MAX_SAFE_INTEGER;
return leftOrder - rightOrder;
});
}
function CellValue({ value, fieldDef }) {
const text = formatFieldValue(value, fieldDef);
if (!text) {
return <span className="text-zinc-400">-</span>;
}
if (fieldDef?.type === 'object' || fieldDef?.type === 'form' || fieldDef?.type === 'subject') {
return (
<span className="block max-w-80 truncate font-mono text-xs text-zinc-600" title={text}>
{text}
</span>
);
}
return (
<span className="block max-w-80 whitespace-normal break-words text-zinc-700">
{text}
</span>
);
}
export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000 }) {
const subjectName = getReferenceSubjectName(fieldDef);
const referenceIds = useMemo(() => getReferenceIds(value), [value]);
const populatedRecords = useMemo(
() => toArray(value).filter(hasRecordBody),
[value],
);
const [subject, setSubject] = useState(null);
const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
const loadReferenceData = async () => {
setError('');
setLoading(false);
setSubject(null);
setRecords([]);
if (referenceIds.length === 0) {
return;
}
if (!subjectName) {
setRecords(populatedRecords);
return;
}
setLoading(true);
try {
const shouldQueryRecords = populatedRecords.length < referenceIds.length;
const [subjectData, recordsResult] = await Promise.all([
loadSubject(subjectName),
shouldQueryRecords
? queryRecords({
subject: subjectName,
page: 0,
page_size: pageSize,
filter: {
_id: { $in: referenceIds },
},
mode: 'full',
})
: Promise.resolve({ records: populatedRecords }),
]);
if (cancelled) {
return;
}
const loadedRecords = recordsResult?.records || populatedRecords;
setSubject(subjectData || null);
setRecords(sortRecordsByReferenceOrder(loadedRecords, referenceIds));
} catch (requestError) {
if (!cancelled) {
setError(requestError?.message || '引用记录加载失败,请稍后重试。');
setSubject(null);
setRecords(populatedRecords);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
loadReferenceData();
return () => {
cancelled = true;
};
}, [pageSize, populatedRecords, referenceIds, subjectName]);
if (referenceIds.length === 0) {
return (
<div className="rounded-lg border border-dashed border-zinc-200 bg-zinc-50 px-4 py-5 text-sm text-zinc-500">
暂无关联记录
</div>
);
}
if (!subjectName && populatedRecords.length === 0) {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
引用字段缺少目标 Subject 配置,暂不能加载关联明细。
</div>
);
}
if (loading) {
return (
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-5 text-sm text-zinc-500 shadow-sm">
正在加载关联明细...
</div>
);
}
const columns = buildColumns(subject, records);
return (
<div className="space-y-2">
{error ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
{error}
</div>
) : null}
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-sm">
<div className="border-b border-zinc-100 px-4 py-3 text-xs text-zinc-500">
共 {records.length} 条关联记录
</div>
{records.length === 0 ? (
<div className="px-4 py-5 text-sm text-zinc-500">暂无可展示的关联明细</div>
) : (
<Table dense grid className="max-h-96 overflow-auto">
<TableHead>
<TableRow>
{columns.map((column) => (
<TableHeader key={column.field} className="bg-zinc-50 text-xs font-semibold text-zinc-600">
{column.label}
</TableHeader>
))}
</TableRow>
</TableHead>
<TableBody>
{records.map((record, rowIndex) => (
<TableRow key={normalizeRecordId(record) || `${record?.name || 'reference'}-${rowIndex}`}>
{columns.map((column) => (
<TableCell key={column.field} className="align-top text-sm">
<CellValue
value={getFieldValue(record, column.field)}
fieldDef={column.fieldDef}
/>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
);
}
import { useEffect, useMemo, useState } from 'react';
import { Input } from '@/catalyst-ui-kit/javascript/input';
import { queryRecords, queryTags, queryViews } from '@/services/DataService';
import {
getReferenceSubjectName,
normalizeRecordId,
normalizeRecordLabel,
toArray,
} from '@/utils/subjectView';
export default function ReferenceSearchSelect({
fieldDef,
value,
onChange,
placeholder,
}) {
const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const fieldType = String(fieldDef?.type || 'reference').toLowerCase();
const subjectName = getReferenceSubjectName(fieldDef);
const multiple = Boolean(fieldDef?.multiple);
const selectedIds = useMemo(
() => toArray(value).map(normalizeRecordId).filter(Boolean),
[value],
);
useEffect(() => {
let cancelled = false;
const loadOptions = async () => {
if (!subjectName && !['view', 'tag'].includes(fieldType)) {
setOptions([]);
return;
}
setLoading(true);
setError('');
try {
const normalizedKeyword = keyword.trim();
let result;
let records;
if (fieldType === 'view') {
result = await queryViews(
0,
20,
normalizedKeyword ? { title: { $regex: normalizedKeyword } } : {},
);
records = result?.views || [];
} else if (fieldType === 'tag') {
result = await queryTags({
page: 0,
page_size: 20,
keyword: normalizedKeyword || undefined,
});
records = result?.tags || [];
} else {
result = await queryRecords({
subject: subjectName,
page: 0,
page_size: 20,
keyword: normalizedKeyword || undefined,
mode: 'basic',
});
records = result?.records || [];
}
if (!cancelled) {
setOptions(records);
}
} catch (requestError) {
if (!cancelled) {
setOptions([]);
setError(requestError?.message || '可选项加载失败');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
loadOptions();
return () => {
cancelled = true;
};
}, [fieldType, keyword, subjectName]);
const toggleRecord = (record, checked) => {
const recordId = normalizeRecordId(record);
if (!recordId) {
return;
}
if (!multiple) {
onChange(checked ? recordId : null);
return;
}
if (checked) {
onChange(Array.from(new Set([...selectedIds, recordId])));
return;
}
onChange(selectedIds.filter((item) => item !== recordId));
};
if (!subjectName && !['view', 'tag'].includes(fieldType)) {
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
引用字段缺少目标 Subject 配置,暂不能搜索选择。
</div>
);
}
return (
<div className="space-y-3">
<Input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder={placeholder || (fieldType === 'view'
? '搜索计算视图'
: fieldType === 'tag' ? '搜索标签' : '搜索引用记录标题')}
/>
<div className="max-h-56 space-y-2 overflow-y-auto rounded-lg border border-zinc-200 bg-zinc-50 p-2">
{loading ? (
<div className="rounded-md bg-white px-3 py-4 text-sm text-zinc-500">正在加载...</div>
) : error ? (
<div className="rounded-md bg-red-50 px-3 py-4 text-sm text-red-700">{error}</div>
) : options.length === 0 ? (
<div className="rounded-md bg-white px-3 py-4 text-sm text-zinc-500">暂无可选记录</div>
) : (
options.map((record) => {
const recordId = normalizeRecordId(record);
const checked = selectedIds.includes(recordId);
return (
<label
key={recordId}
className="flex cursor-pointer items-center gap-3 rounded-md bg-white px-3 py-2 text-sm text-zinc-700 shadow-sm transition hover:bg-blue-50"
>
<input
type={multiple ? 'checkbox' : 'radio'}
checked={checked}
onChange={(event) => toggleRecord(record, event.target.checked)}
/>
<span>
<span className="block font-medium text-zinc-950">{normalizeRecordLabel(record) || recordId}</span>
<span className="block text-xs text-zinc-500">{record.name || recordId}</span>
</span>
</label>
);
})
)}
</div>
</div>
);
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import RiskDataWorkspace from './RiskDataWorkspace';
export default function DisplayRiskTable() {
return <RiskDataWorkspace workspaceKey="display" />;
}
import RiskDataWorkspace from './RiskDataWorkspace';
export default function MeasureDatabase() {
return <RiskDataWorkspace workspaceKey="measure" />;
}
import RiskDataWorkspace from './RiskDataWorkspace';
export default function ProcessRiskTable() {
return <RiskDataWorkspace workspaceKey="process" />;
}
import RiskDataWorkspace from './RiskDataWorkspace';
export default function RiskArchive() {
return <RiskDataWorkspace workspaceKey="archive" />;
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import IntegrityRiskWorkbenchPage from './IntegrityRiskWorkbenchPage';
export default function IntegrityRiskArchivePage() {
return <IntegrityRiskWorkbenchPage pageType="archive" />;
}
This diff is collapsed.
This diff is collapsed.
import IntegrityRiskWorkbenchPage from './IntegrityRiskWorkbenchPage';
export default function MeasureDescriptionPage() {
return <IntegrityRiskWorkbenchPage pageType="measure" />;
}
import IntegrityRiskWorkbenchPage from './IntegrityRiskWorkbenchPage';
export default function RiskPreventionShowTablePage() {
return <IntegrityRiskWorkbenchPage pageType="show" />;
}
import IntegrityRiskWorkbenchPage from './IntegrityRiskWorkbenchPage';
export default function RiskPreventionTablePage() {
return <IntegrityRiskWorkbenchPage pageType="process" />;
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment