feat: 鏍囬鏍忕増鏈彿 + 搴旂敤甯傚満涓婃灦鏃ュ巻搴旂敤涓庡垎缁勬姌鍙?+ 鏃ョ▼鍐滃巻/琛ㄥ崟妯増浼樺寲 + 涓汉璧勬枡寮圭獥
This commit is contained in:
@@ -33,3 +33,10 @@ client/release/
|
||||
# local tooling data
|
||||
.codebuddy/
|
||||
.playwright-cli/
|
||||
|
||||
# temp debug artifacts (screenshots / UI dumps)
|
||||
/apps-calendar-lite.png
|
||||
/apps-group-collapse.png
|
||||
/schedule-lunar.png
|
||||
/forms.yaml
|
||||
/forms2.yaml
|
||||
|
||||
@@ -9,7 +9,7 @@ function createWindow() {
|
||||
height: 800,
|
||||
minWidth: 980,
|
||||
minHeight: 640,
|
||||
title: 'ChatOA',
|
||||
title: `ChatOA v${app.getVersion()}`,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
|
||||
Generated
+7
@@ -12,6 +12,7 @@
|
||||
"antd": "^5.22.0",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.13",
|
||||
"lunar-typescript": "^1.8.6",
|
||||
"quill": "^1.3.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -4956,6 +4957,12 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lunar-typescript": {
|
||||
"version": "1.8.6",
|
||||
"resolved": "https://registry.npmjs.org/lunar-typescript/-/lunar-typescript-1.8.6.tgz",
|
||||
"integrity": "sha512-5Eo4T/cnuXfrgO4k5LCpOGHIUOuz5hCF/IfNv0T29WY2shR36Hiz+ecN9WjnUuxUKhql9gbOkPaQoqLFKtPRNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/matcher": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"antd": "^5.22.0",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.13",
|
||||
"lunar-typescript": "^1.8.6",
|
||||
"quill": "^1.3.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
+4
-1
@@ -58,7 +58,10 @@ export default function App() {
|
||||
<Route path="/knowledge" element={<KeepAliveOutlet />} />
|
||||
<Route path="/apps" element={<KeepAliveOutlet />} />
|
||||
<Route path="/drive" element={<KeepAliveOutlet />} />
|
||||
<Route path="/ai-chat" element={<Navigate to="/ai-chat/assistant" replace />} />
|
||||
{/* 注意:/ai-chat 必须与 /ai-chat/:assistantId 都渲染 KeepAliveOutlet(相同类型),
|
||||
若用 <Navigate> 会把 Outlet 位置的 KeepAliveOutlet 卸载,导致切走再切回时
|
||||
所有 keep-alive 页面状态(如应用页打开的运行页签)被重置 */}
|
||||
<Route path="/ai-chat" element={<KeepAliveOutlet />} />
|
||||
<Route path="/ai-chat/:assistantId" element={<KeepAliveOutlet />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/chat" replace />} />
|
||||
|
||||
@@ -22,3 +22,8 @@ export async function fetchMe() {
|
||||
const { data } = await client.get<User>('/auth/me');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateMe(patch: { signature?: string; status?: string }) {
|
||||
const { data } = await client.patch<User>('/auth/me', patch);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Descriptions,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Tag,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { useAuth } from '../store/auth';
|
||||
import { USER_STATUS, userStatusMeta } from '../constants/user';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** 个人信息弹窗:展示资料 + 编辑签名 + 设置状态 */
|
||||
export default function ProfileModal({ open, onClose }: Props) {
|
||||
const user = useAuth((s) => s.user);
|
||||
const updateProfile = useAuth((s) => s.updateProfile);
|
||||
const [signature, setSignature] = useState('');
|
||||
const [status, setStatus] = useState<string | undefined>(undefined);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 每次打开时同步当前用户数据
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSignature(user?.signature ?? '');
|
||||
setStatus(user?.status);
|
||||
}
|
||||
}, [open, user]);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateProfile({
|
||||
signature: signature.trim(),
|
||||
status: status && status !== user?.status ? status : undefined,
|
||||
});
|
||||
message.success('已保存');
|
||||
onClose();
|
||||
} catch {
|
||||
message.error('保存失败,请重试');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusMeta = userStatusMeta(user?.status);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="个人信息"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onClose}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button key="save" type="primary" loading={saving} onClick={save}>
|
||||
保存
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Avatar size={56} style={{ background: '#07c160', fontSize: 24 }}>
|
||||
{user?.nickname?.[0] ?? 'U'}
|
||||
</Avatar>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>
|
||||
{user?.nickname}
|
||||
{statusMeta && (
|
||||
<Tag color={statusMeta.color} style={{ marginLeft: 8 }}>
|
||||
{statusMeta.label}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color: 'rgba(0,0,0,0.45)', fontSize: 12 }}>
|
||||
@{user?.username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Descriptions
|
||||
column={1}
|
||||
size="small"
|
||||
labelStyle={{ width: 90, color: 'rgba(0,0,0,0.45)' }}
|
||||
items={[
|
||||
{ key: 'dept', label: '部门', children: user?.department || '—' },
|
||||
{ key: 'title', label: '职位', children: user?.title || '—' },
|
||||
{ key: 'email', label: '邮箱', children: user?.email || '—' },
|
||||
{
|
||||
key: 'created',
|
||||
label: '注册时间',
|
||||
children: new Date(user?.createdAt ?? Date.now()).toLocaleString(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ marginBottom: 8, fontSize: 13, fontWeight: 600 }}>
|
||||
个性签名
|
||||
</div>
|
||||
<Input
|
||||
placeholder="介绍一下自己吧~"
|
||||
value={signature}
|
||||
maxLength={50}
|
||||
showCount
|
||||
onChange={(e) => setSignature(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ marginBottom: 8, fontSize: 13, fontWeight: 600 }}>
|
||||
我的状态
|
||||
</div>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder="选择当前状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
options={USER_STATUS.map((s) => ({
|
||||
value: s.value,
|
||||
label: (
|
||||
<span>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: s.color,
|
||||
marginRight: 8,
|
||||
}}
|
||||
/>
|
||||
{s.label}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface UserStatusMeta {
|
||||
value: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** 内置个人状态(用户可在个人信息弹窗中设置) */
|
||||
export const USER_STATUS: UserStatusMeta[] = [
|
||||
{ value: 'work', label: '上班', color: '#07c160' },
|
||||
{ value: 'out', label: '外出', color: '#1890ff' },
|
||||
{ value: 'trip', label: '出差', color: '#722ed1' },
|
||||
{ value: 'leave', label: '请假', color: '#fa8c16' },
|
||||
{ value: 'study', label: '学习', color: '#13c2c2' },
|
||||
{ value: 'holiday', label: '休假', color: '#eb2f96' },
|
||||
];
|
||||
|
||||
export function userStatusMeta(status?: string): UserStatusMeta | undefined {
|
||||
return USER_STATUS.find((s) => s.value === status);
|
||||
}
|
||||
@@ -54,7 +54,9 @@ export default function KeepAliveOutlet() {
|
||||
const renderKeys = mountedKeys.includes(activeKey) ? mountedKeys : [...mountedKeys, activeKey];
|
||||
|
||||
// AI 对话页的助手 id 从路径解析(常驻后不再依赖 Route 的 useParams)
|
||||
const assistantId = activeKey === 'ai-chat' ? location.pathname.split('/')[2] : undefined;
|
||||
// /ai-chat(无 id)时兜底默认助手 'assistant',与原 Navigate 重定向行为一致
|
||||
const assistantId =
|
||||
activeKey === 'ai-chat' ? location.pathname.split('/')[2] || 'assistant' : undefined;
|
||||
const lastAssistantIdRef = useRef<string | undefined>(undefined);
|
||||
if (assistantId) lastAssistantIdRef.current = assistantId;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Avatar, Badge, Tooltip } from 'antd';
|
||||
import {
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../store/auth';
|
||||
import { useChat } from '../store/chat';
|
||||
import { userStatusMeta } from '../constants/user';
|
||||
import ProfileModal from '../components/ProfileModal';
|
||||
import { connectSocket, disconnectSocket, onMessage, onPresence, onPresenceInit } from '../socket/socket';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
@@ -23,14 +25,16 @@ const NAV_ITEMS = [
|
||||
{ key: '/schedule', icon: <CalendarOutlined />, label: '日程' },
|
||||
{ key: '/forms', icon: <FormOutlined />, label: '表单' },
|
||||
{ key: '/knowledge', icon: <BookOutlined />, label: '笔记' },
|
||||
{ key: '/apps', icon: <AppstoreOutlined />, label: '应用' },
|
||||
{ key: '/drive', icon: <CloudOutlined />, label: '网盘' },
|
||||
{ key: '/apps', icon: <AppstoreOutlined />, label: '应用' },
|
||||
];
|
||||
|
||||
export default function MainLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const user = useAuth((s) => s.user);
|
||||
const statusMeta = userStatusMeta(user?.status);
|
||||
const token = useAuth((s) => s.token);
|
||||
const logout = useAuth((s) => s.logout);
|
||||
const totalUnread = useChat((s) =>
|
||||
@@ -120,15 +124,64 @@ export default function MainLayout() {
|
||||
<LogoutOutlined />
|
||||
</div>
|
||||
</Tooltip>
|
||||
{/* 个人头像:点击打开个人信息弹窗(展示资料 / 修改签名 / 设置状态) */}
|
||||
<Tooltip
|
||||
title={
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div>
|
||||
{user?.nickname}
|
||||
{statusMeta ? ` · ${statusMeta.label}` : ''}
|
||||
</div>
|
||||
{user?.signature ? (
|
||||
<div style={{ fontSize: 12, opacity: 0.8, marginTop: 2 }}>
|
||||
{user.signature}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
>
|
||||
<div
|
||||
onClick={() => setProfileOpen(true)}
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Avatar size={36} style={{ background: '#07c160' }}>
|
||||
{user?.nickname?.[0] ?? 'U'}
|
||||
</Avatar>
|
||||
{statusMeta && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: statusMeta.color,
|
||||
border: '2px solid #2e2e2e',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 第二列 + 第三列:由子路由页面渲染 */}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
<ProfileModal open={profileOpen} onClose={() => setProfileOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import './index.css';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
// 窗口标题带版本号,便于运行时确认版本迭代
|
||||
document.title = `ChatOA v${__APP_VERSION__}`;
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider
|
||||
|
||||
+111
-8
@@ -17,6 +17,9 @@ import {
|
||||
ClockCircleOutlined,
|
||||
CloudOutlined,
|
||||
DeleteOutlined,
|
||||
DownOutlined,
|
||||
DoubleLeftOutlined,
|
||||
DoubleRightOutlined,
|
||||
ExportOutlined,
|
||||
FileTextOutlined,
|
||||
FormOutlined,
|
||||
@@ -194,6 +197,11 @@ const EXTERNAL_PROJECTS: Record<string, { name: string; path?: string; url: stri
|
||||
name: '豆包网页版',
|
||||
url: 'https://www.doubao.com/',
|
||||
},
|
||||
'market-calendar-lite': {
|
||||
name: '极简日历(本地版)',
|
||||
path: 'C:\\Users\\范先生\\CodeBuddy\\极简日历',
|
||||
url: 'https://calendarlite.bbitcn.net/',
|
||||
},
|
||||
};
|
||||
|
||||
const MARKET_APPS: MockApp[] = [
|
||||
@@ -211,6 +219,20 @@ const MARKET_APPS: MockApp[] = [
|
||||
version: '2.3.1',
|
||||
isNew: true,
|
||||
},
|
||||
{
|
||||
id: 'market-calendar-lite',
|
||||
name: '极简日历',
|
||||
desc: '与喜欢的人一起数日子。农历万年历、节气节日、日程管理、每日一句与随笔一记,支持专属空间与二维码分享。',
|
||||
source: 'market',
|
||||
category: '效率',
|
||||
group: '精品推荐',
|
||||
installed: false,
|
||||
icon: <CalendarOutlined />,
|
||||
color: '#c1440e',
|
||||
author: '极简日历工作室',
|
||||
version: '网页版',
|
||||
isNew: true,
|
||||
},
|
||||
{
|
||||
id: 'market-robot',
|
||||
name: '智能助手',
|
||||
@@ -367,6 +389,11 @@ export default function AppsPage() {
|
||||
);
|
||||
const [appTabs, setAppTabs] = useState<AppTab[]>([]);
|
||||
const [activeTabKey, setActiveTabKey] = useState<string>('intro');
|
||||
/** 是否折叠左侧面板(隐藏后右侧应用区更大) */
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const [sashHover, setSashHover] = useState(false);
|
||||
/** 「我的应用」分组折叠状态(默认全部展开,行政组空时自动折叠) */
|
||||
const [collapsedCats, setCollapsedCats] = useState<Set<string>>(new Set());
|
||||
|
||||
/** 打开应用:内部模块 / 外部项目均在本窗体新增运行页签 */
|
||||
const handleUse = () => {
|
||||
@@ -483,12 +510,14 @@ export default function AppsPage() {
|
||||
{/* 左列:我的应用 / 应用市场 两个 Tab + 搜索(宽度与聊天会话列表一致) */}
|
||||
<aside
|
||||
style={{
|
||||
width: SIDEBAR_WIDTH,
|
||||
width: leftCollapsed ? 0 : SIDEBAR_WIDTH,
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid #e8e8e8',
|
||||
background: '#f7f7f7',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
transition: 'width 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{/* 标题区:固定高度,底部横线 */}
|
||||
@@ -528,11 +557,54 @@ export default function AppsPage() {
|
||||
}}
|
||||
>
|
||||
{tab === 'installed' ? (
|
||||
installedByCat.map(({ category, apps }) => (
|
||||
<div key={category} style={{ marginBottom: 6 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, padding: '4px 10px' }}>
|
||||
{category}
|
||||
</Typography.Text>
|
||||
installedByCat.map(({ category, apps }) => {
|
||||
const collapsed = collapsedCats.has(category);
|
||||
return (
|
||||
<div key={category} style={{ marginBottom: 2 }}>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (apps.length === 0) return;
|
||||
setCollapsedCats((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(category)) next.delete(category);
|
||||
else next.add(category);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
title={apps.length === 0 ? '该分组暂无应用' : collapsed ? '展开分组' : '折叠分组'}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '6px 10px',
|
||||
fontSize: 12,
|
||||
color: 'rgba(0,0,0,0.45)',
|
||||
cursor: apps.length === 0 ? 'default' : 'pointer',
|
||||
userSelect: 'none',
|
||||
borderRadius: 4,
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (apps.length > 0) e.currentTarget.style.background = 'rgba(0,0,0,0.03)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<DownOutlined
|
||||
style={{
|
||||
fontSize: 10,
|
||||
transition: 'transform 0.2s',
|
||||
transform: collapsed ? 'rotate(-90deg)' : undefined,
|
||||
opacity: apps.length === 0 ? 0.35 : 0.85,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontWeight: 500 }}>{category}</span>
|
||||
<span style={{ marginLeft: 'auto', color: '#bbb', fontSize: 11 }}>
|
||||
{apps.length}
|
||||
</span>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={apps}
|
||||
@@ -540,8 +612,10 @@ export default function AppsPage() {
|
||||
split={false}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
) : (
|
||||
marketByGroup.map(({ group, apps }) => (
|
||||
<div key={group} style={{ marginBottom: 6 }}>
|
||||
@@ -575,6 +649,34 @@ export default function AppsPage() {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 分割热区:沿用左侧栏统一的 1px 分割线风格,仅作为折叠按钮承载区 */}
|
||||
<div
|
||||
onClick={() => setLeftCollapsed((c) => !c)}
|
||||
title={leftCollapsed ? '展开左侧面板' : '隐藏左侧面板'}
|
||||
onMouseEnter={() => setSashHover(true)}
|
||||
onMouseLeave={() => setSashHover(false)}
|
||||
style={{
|
||||
width: 14,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: leftCollapsed || sashHover ? '#07c160' : '#d9d9d9',
|
||||
transition: 'color 0.2s',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{leftCollapsed ? <DoubleRightOutlined /> : <DoubleLeftOutlined />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 右列:多页签(应用介绍 + 运行中的应用) */}
|
||||
<section
|
||||
style={{
|
||||
@@ -799,7 +901,7 @@ export default function AppsPage() {
|
||||
})()}
|
||||
|
||||
{/* 功能预览卡片 */}
|
||||
{['chat', 'contacts', 'schedule', 'forms', 'knowledge', 'crm', 'doubao'].includes(selected.id) ? (
|
||||
{['chat', 'contacts', 'schedule', 'forms', 'knowledge', 'crm', 'doubao', 'market-calendar-lite'].includes(selected.id) ? (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Typography.Title level={5}>功能预览</Typography.Title>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 10 }}>
|
||||
@@ -929,4 +1031,5 @@ const FEATURES: Record<string, string[]> = {
|
||||
knowledge: ['多级目录', '全文搜索', '团队协作'],
|
||||
crm: ['客户档案', '联系记录', '项目跟踪', '财务往来', '服务费提醒', '附件管理'],
|
||||
doubao: ['对话问答', '写作创作', '翻译润色', '内容总结', '代码辅助'],
|
||||
'market-calendar-lite': ['农历万年历', '节气节日', '日程管理', '每日一句', '随笔一记', '专属空间分享'],
|
||||
};
|
||||
|
||||
@@ -539,7 +539,7 @@ export default function FormsPage() {
|
||||
<div className="form-mini-calendar" style={{ padding: '0 8px 4px' }}>
|
||||
<Calendar
|
||||
fullscreen={false}
|
||||
value={selectedDate ?? undefined}
|
||||
value={selectedDate ?? dayjs()}
|
||||
onSelect={(date) =>
|
||||
setSelectedDate(
|
||||
selectedDate && selectedDate.isSame(date, 'day') ? null : date,
|
||||
@@ -558,7 +558,7 @@ export default function FormsPage() {
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>
|
||||
{value.format('YYYY年M月')}
|
||||
{value?.format('YYYY年M月') ?? ''}
|
||||
</Typography.Text>
|
||||
<Space size={2}>
|
||||
<Button
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '../api/schedule';
|
||||
import { useAuth } from '../store/auth';
|
||||
import { SIDEBAR_WIDTH } from '../layout/constants';
|
||||
import { getLunarDayInfo, getYearMonthGanZhi } from '../utils/lunar';
|
||||
|
||||
const DAY = 24 * 3600_000;
|
||||
|
||||
@@ -121,7 +122,42 @@ export default function SchedulePage() {
|
||||
const fullDateCellRender = (date: Dayjs) => {
|
||||
const scheds = byDate[date.format('YYYY-MM-DD')] ?? [];
|
||||
const sp = specialOf(date);
|
||||
const lun = getLunarDayInfo(date);
|
||||
// 万年历角标:节日 > 节气 > 农历日;初一显示「X月初一」(如「七月初一」);节日/节气/初一用红色
|
||||
const corner =
|
||||
lun.festival ||
|
||||
lun.jieQi ||
|
||||
(lun.lunarDay === '初一' ? `${lun.lunarMonth}月初一` : lun.lunarDay);
|
||||
const cornerColor =
|
||||
lun.festival || lun.jieQi || lun.lunarDay === '初一'
|
||||
? '#f5222d'
|
||||
: 'rgba(0, 0, 0, 0.35)';
|
||||
// Tooltip:完整农历月日;当天有节日/节气时带名称,如「建军节(农历六月十九)」
|
||||
const lunarFull = `农历${lun.lunarMonth}月${lun.lunarDay}`;
|
||||
const cornerTitle = lun.festival || lun.jieQi ? `${corner}(${lunarFull})` : lunarFull;
|
||||
return (
|
||||
<>
|
||||
{/* 角标放格子左上角:antd 日期数字是右对齐 block(右上角),
|
||||
放右上角会与公历日期重叠(曾实际踩坑) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
left: 8,
|
||||
fontSize: 10,
|
||||
lineHeight: '14px',
|
||||
color: cornerColor,
|
||||
zIndex: 2,
|
||||
maxWidth: '55%',
|
||||
textAlign: 'left',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={cornerTitle}
|
||||
>
|
||||
{corner}
|
||||
</div>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: 12 }}>
|
||||
{sp.map((s) => (
|
||||
<li
|
||||
@@ -150,6 +186,7 @@ export default function SchedulePage() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -519,6 +556,9 @@ export default function SchedulePage() {
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{month.format('YYYY年M月')}
|
||||
</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#fa541c', whiteSpace: 'nowrap' }}>
|
||||
{getYearMonthGanZhi(month.date(15))}
|
||||
</Typography.Text>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openCreate(selectedDate)}>
|
||||
新建日程
|
||||
@@ -528,6 +568,7 @@ export default function SchedulePage() {
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>
|
||||
<Calendar
|
||||
fullscreen
|
||||
className="schedule-full-calendar"
|
||||
value={month}
|
||||
onPanelChange={(m) => setMonth(m)}
|
||||
onSelect={(date) => {
|
||||
@@ -539,6 +580,17 @@ export default function SchedulePage() {
|
||||
}
|
||||
style={{ background: '#fff', borderRadius: 8, padding: 8 }}
|
||||
/>
|
||||
<style>{`
|
||||
/* 大日历:农历角标(absolute 定位)的定位基准 */
|
||||
.schedule-full-calendar .ant-picker-cell-inner {
|
||||
position: relative;
|
||||
}
|
||||
/* 大日历:休息日(周六/周日)日期数字红色(选中除外),与左侧小日历风格一致 */
|
||||
.schedule-full-calendar .ant-picker-content .ant-picker-cell:nth-child(7n+1):not(.ant-picker-cell-selected) .ant-picker-calendar-date-value,
|
||||
.schedule-full-calendar .ant-picker-content .ant-picker-cell:nth-child(7n+7):not(.ant-picker-cell-selected) .ant-picker-calendar-date-value {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import type { User } from '../types';
|
||||
import { login as apiLogin, fetchMe } from '../api/auth';
|
||||
import { login as apiLogin, fetchMe, updateMe as apiUpdateMe } from '../api/auth';
|
||||
import { connectSocket, disconnectSocket } from '../socket/socket';
|
||||
|
||||
interface AuthState {
|
||||
@@ -10,6 +10,8 @@ interface AuthState {
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
init: () => Promise<void>;
|
||||
/** 更新个人资料(签名、状态等) */
|
||||
updateProfile: (patch: { signature?: string; status?: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
function saveToken(token: string | null, user: User | null) {
|
||||
@@ -52,4 +54,10 @@ export const useAuth = create<AuthState>((set, get) => ({
|
||||
saveToken(null, null);
|
||||
set({ user: null, token: null });
|
||||
},
|
||||
|
||||
updateProfile: async (patch) => {
|
||||
const updated = await apiUpdateMe(patch);
|
||||
saveToken(get().token, updated);
|
||||
set({ user: updated });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -6,6 +6,10 @@ export interface User {
|
||||
email?: string;
|
||||
department?: string;
|
||||
title?: string;
|
||||
/** 个人签名 */
|
||||
signature?: string;
|
||||
/** 自定义状态:上班/外出/出差/请假/学习/休假 等 */
|
||||
status?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Lunar, Solar } from 'lunar-typescript';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
/** 单日万年历信息 */
|
||||
export interface LunarDayInfo {
|
||||
/** 农历日,如「廿三」「初一」 */
|
||||
lunarDay: string;
|
||||
/** 农历月,如「正」「腊」(不含闰字) */
|
||||
lunarMonth: string;
|
||||
/** 节气名,如「立春」;非节气日为空串 */
|
||||
jieQi: string;
|
||||
/** 节日名(优先农历节日,其次公历节日),如「春节」「国庆节」;无则空串 */
|
||||
festival: string;
|
||||
/** 日干支,如「丙午」 */
|
||||
dayGanZhi: string;
|
||||
}
|
||||
|
||||
const lunarCache = new Map<number, LunarDayInfo>();
|
||||
|
||||
/** 获取某日的万年历信息(含简单缓存,避免整月渲染重复计算) */
|
||||
export function getLunarDayInfo(date: Dayjs): LunarDayInfo {
|
||||
const ms = date.startOf('day').valueOf();
|
||||
const cached = lunarCache.get(ms);
|
||||
if (cached) return cached;
|
||||
|
||||
const d = date.toDate();
|
||||
const lunar = Lunar.fromDate(d);
|
||||
const solar = Solar.fromDate(d);
|
||||
// lunar.getMonth():负数表示闰月
|
||||
const isLeap = lunar.getMonth() < 0;
|
||||
const info: LunarDayInfo = {
|
||||
lunarDay: lunar.getDayInChinese(),
|
||||
lunarMonth: `${isLeap ? '闰' : ''}${lunar.getMonthInChinese()}`,
|
||||
jieQi: lunar.getJieQi(),
|
||||
festival: lunar.getFestivals()[0] || solar.getFestivals()[0] || '',
|
||||
dayGanZhi: lunar.getDayInGanZhi(),
|
||||
};
|
||||
if (lunarCache.size > 400) lunarCache.clear();
|
||||
lunarCache.set(ms, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
/** 某年某月的年干支 + 月干支,如「丙午年 乙未月」 */
|
||||
export function getYearMonthGanZhi(date: Dayjs): string {
|
||||
const lunar = Lunar.fromDate(date.toDate());
|
||||
return `${lunar.getYearInGanZhi()}年 ${lunar.getMonthInGanZhi()}月`;
|
||||
}
|
||||
Vendored
+3
@@ -1 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/** 由 vite.config.ts 注入的客户端版本号(来源 package.json version) */
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// 以 package.json 的 version 作为客户端版本号唯一来源,注入到前端
|
||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8'));
|
||||
|
||||
// 浏览器调试与 Electron 开发共用
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
base: './',
|
||||
server: {
|
||||
port: 5173,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
import { CurrentUser } from './current-user.decorator';
|
||||
@@ -24,6 +24,15 @@ export class AuthController {
|
||||
return user;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('me')
|
||||
updateMe(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: { signature?: string; status?: string },
|
||||
) {
|
||||
return this.auth.updateProfile(user.id, dto);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('logout')
|
||||
logout(@Req() _req: any) {
|
||||
|
||||
@@ -17,6 +17,10 @@ export interface User {
|
||||
email?: string;
|
||||
department?: string;
|
||||
title?: string;
|
||||
/** 个人签名 */
|
||||
signature?: string;
|
||||
/** 自定义状态:上班/外出/出差/请假/学习/休假 等 */
|
||||
status?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@@ -86,6 +90,16 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新个人资料(签名、自定义状态等) */
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
patch: { signature?: string; status?: string },
|
||||
) {
|
||||
const user = await this.users().updateById(userId, patch);
|
||||
if (!user) throw new BadRequestException('用户不存在');
|
||||
return this.toPublic(user);
|
||||
}
|
||||
|
||||
toPublic(user: User): PublicUser {
|
||||
const { passwordHash, ...rest } = user;
|
||||
return rest;
|
||||
|
||||
@@ -26,3 +26,18 @@
|
||||
- 离线消息推送
|
||||
|
||||
如果还有问题,请及时提出。
|
||||
|
||||
|
||||
|
||||
知识库的设计逻辑为:笔记,分左右结构,左边为多级目录,支持搜索,左侧区域的宽度等于聊天界面对话列表区域的宽度(1-7个模块,需要全局统一宽度),右侧为笔记编辑界面。
|
||||
|
||||
应用页面:分左右结构,左边为两个Tab,一个为我的应用,另一个为应用市场,支持搜索,右侧为应用的展示、操作区,模拟几个应用。
|
||||
网盘页面:先做Demo,分左右结构,左边为 搜索结果,本机文件,我的文件,团队文件,系统文件,分享文件,回收站 几个导航菜单,底部显示云盘的容量和使用情况。右侧为文件夹显示区域。
|
||||
日程页面:分左右结果,左侧区域上面一个小型月历视图,用点标记日程、生日、纪念日等,左侧下方显示 分多个tab页:今日(默认)、明日、一周内等信息。右侧为大型月历视图。
|
||||
|
||||
知识库(笔记)页面调整项:1图标和文字需要保持在同一行。2左边文件夹目录后面显示笔记条数。3右侧编辑区需要支持多页签。4笔记编辑组件采用html富文本编辑器。
|
||||
|
||||
联系人页面:新增分组:同事(组织内好友)|AI助手(AI助手工具:单击后进入AI对话聊天界面,模型支持多种模型可选)|朋友(指非组织内朋友,通过添加好友的) 。
|
||||
|
||||
系统框架:
|
||||
首页左下角个人头像图标,单击后 进行显示个人信息并修改签名、设置状态(内置 上班、外出、出差、请假、学习、休假等状态)
|
||||
|
||||
Reference in New Issue
Block a user