feat: 鏍囬鏍忕増鏈彿 + 搴旂敤甯傚満涓婃灦鏃ュ巻搴旂敤涓庡垎缁勬姌鍙?+ 鏃ョ▼鍐滃巻/琛ㄥ崟妯増浼樺寲 + 涓汉璧勬枡寮圭獥

This commit is contained in:
2026-08-23 08:55:08 +08:00
parent 62776aa33b
commit 66f107058e
22 changed files with 545 additions and 29 deletions
+7
View File
@@ -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
+1 -1
View File
@@ -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'),
+7
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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 />} />
+5
View File
@@ -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;
}
+154
View File
@@ -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>
);
}
+19
View File
@@ -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);
}
+3 -1
View File
@@ -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;
+58 -5
View File
@@ -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>
<Avatar size={36} style={{ background: '#07c160' }}>
{user?.nickname?.[0] ?? 'U'}
</Avatar>
{/* 个人头像:点击打开个人信息弹窗(展示资料 / 修改签名 / 设置状态) */}
<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>
);
}
+3
View File
@@ -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
+119 -16
View File
@@ -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,20 +557,65 @@ 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>
<List
size="small"
dataSource={apps}
locale={{ emptyText: null }}
split={false}
renderItem={renderItem}
/>
</div>
))
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}
locale={{ emptyText: null }}
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': ['农历万年历', '节气节日', '日程管理', '每日一句', '随笔一记', '专属空间分享'],
};
+2 -2
View File
@@ -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
+53 -1
View File
@@ -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,8 +122,43 @@ 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 (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: 12 }}>
<>
{/* 角标放格子左上角: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
key={`sp-${s.kind}-${s.md}`}
@@ -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>
+9 -1
View File
@@ -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 });
},
}));
+4
View File
@@ -6,6 +6,10 @@ export interface User {
email?: string;
department?: string;
title?: string;
/** 个人签名 */
signature?: string;
/** 自定义状态:上班/外出/出差/请假/学习/休假 等 */
status?: string;
createdAt: number;
}
+47
View File
@@ -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()}`;
}
+3
View File
@@ -1 +1,4 @@
/// <reference types="vite/client" />
/** 由 vite.config.ts 注入的客户端版本号(来源 package.json version */
declare const __APP_VERSION__: string;
+7
View File
@@ -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,
+10 -1
View File
@@ -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) {
+14
View File
@@ -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;
+15
View File
@@ -26,3 +26,18 @@
- 离线消息推送
如果还有问题,请及时提出。
知识库的设计逻辑为:笔记,分左右结构,左边为多级目录,支持搜索,左侧区域的宽度等于聊天界面对话列表区域的宽度(1-7个模块,需要全局统一宽度),右侧为笔记编辑界面。
应用页面:分左右结构,左边为两个Tab,一个为我的应用,另一个为应用市场,支持搜索,右侧为应用的展示、操作区,模拟几个应用。
网盘页面:先做Demo,分左右结构,左边为 搜索结果,本机文件,我的文件,团队文件,系统文件,分享文件,回收站 几个导航菜单,底部显示云盘的容量和使用情况。右侧为文件夹显示区域。
日程页面:分左右结果,左侧区域上面一个小型月历视图,用点标记日程、生日、纪念日等,左侧下方显示 分多个tab页:今日(默认)、明日、一周内等信息。右侧为大型月历视图。
知识库(笔记)页面调整项:1图标和文字需要保持在同一行。2左边文件夹目录后面显示笔记条数。3右侧编辑区需要支持多页签。4笔记编辑组件采用html富文本编辑器。
联系人页面:新增分组:同事(组织内好友)|AI助手(AI助手工具:单击后进入AI对话聊天界面,模型支持多种模型可选)|朋友(指非组织内朋友,通过添加好友的) 。
系统框架:
首页左下角个人头像图标,单击后 进行显示个人信息并修改签名、设置状态(内置 上班、外出、出差、请假、学习、休假等状态)