修改样式

main
kangwenjing 2026-04-03 10:11:19 +08:00
parent 19d8cf7e9b
commit 67c41f8e88
10 changed files with 654 additions and 141 deletions

View File

@ -7,6 +7,7 @@ public class ChannelExpansionItemDTO {
private Long id;
private Long ownerUserId;
private String owner;
private String type;
private String channelCode;
private String name;
@ -57,6 +58,14 @@ public class ChannelExpansionItemDTO {
this.ownerUserId = ownerUserId;
}
public String getOwner() {
return owner;
}
public void setOwner(String owner) {
this.owner = owner;
}
public String getType() {
return type;
}

View File

@ -7,6 +7,7 @@ public class SalesExpansionItemDTO {
private Long id;
private Long ownerUserId;
private String owner;
private String type;
private String employeeNo;
private String name;
@ -48,6 +49,14 @@ public class SalesExpansionItemDTO {
this.ownerUserId = ownerUserId;
}
public String getOwner() {
return owner;
}
public void setOwner(String owner) {
this.owner = owner;
}
public String getType() {
return type;
}

View File

@ -15,6 +15,7 @@ import com.unis.crm.dto.work.WorkTomorrowPlanItemDTO;
import com.unis.crm.dto.work.WorkTomorrowPlanItemRequest;
import com.unis.crm.dto.work.WorkOverviewDTO;
import com.unis.crm.dto.work.WorkSuggestedActionDTO;
import com.unis.crm.mapper.ProfileMapper;
import com.unis.crm.mapper.WorkMapper;
import com.unis.crm.service.WorkService;
import java.io.IOException;
@ -67,6 +68,7 @@ public class WorkServiceImpl implements WorkService {
private static final String PLAN_ITEMS_METADATA_PREFIX = "[[WORK_PLAN_ITEMS]]";
private static final String PLAN_ITEMS_METADATA_SUFFIX = "[[/WORK_PLAN_ITEMS]]";
private static final Pattern PLAN_ITEMS_METADATA_PATTERN = Pattern.compile("\\[\\[WORK_PLAN_ITEMS]](.*?)\\[\\[/WORK_PLAN_ITEMS]]", Pattern.DOTALL);
private static final String ONLY_SEE_ROLE_CODE = "only_see";
private static final String WORK_REPORT_FOLLOW_UP_TYPE = "工作日报";
private static final String TENCENT_COORD_TYPE_GPS = "1";
private static final String TENCENT_COORD_TYPE_GCJ02 = "2";
@ -77,6 +79,7 @@ public class WorkServiceImpl implements WorkService {
private static final DateTimeFormatter REPORT_DATE_TIME_TEXT_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm");
private final WorkMapper workMapper;
private final ProfileMapper profileMapper;
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
private final Path checkInPhotoDirectory;
@ -85,10 +88,12 @@ public class WorkServiceImpl implements WorkService {
public WorkServiceImpl(
WorkMapper workMapper,
ProfileMapper profileMapper,
ObjectMapper objectMapper,
@Value("${unisbase.app.upload-path}") String uploadPath,
@Value("${unisbase.app.tencent-map.key:}") String tencentMapKey) {
this.workMapper = workMapper;
this.profileMapper = profileMapper;
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(8))
@ -132,6 +137,7 @@ public class WorkServiceImpl implements WorkService {
@Override
public Long saveCheckIn(Long userId, CreateWorkCheckInRequest request) {
requireUser(userId);
ensureWorkWriteAllowed(userId, "当前角色仅可查看打卡历史记录");
request.setLocationText(normalizeOptionalText(request.getLocationText()));
request.setRemark(normalizeOptionalText(request.getRemark()));
request.setBizType(normalizeBizType(request.getBizType()));
@ -164,6 +170,7 @@ public class WorkServiceImpl implements WorkService {
@Transactional
public Long saveDailyReport(Long userId, CreateWorkDailyReportRequest request) {
requireUser(userId);
ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录");
normalizeDailyReportRequest(request);
request.setTomorrowPlan(request.getTomorrowPlan().trim());
request.setSourceType(normalizeOptionalText(request.getSourceType()));
@ -234,6 +241,7 @@ public class WorkServiceImpl implements WorkService {
@Override
public String uploadCheckInPhoto(Long userId, MultipartFile file) {
requireUser(userId);
ensureWorkWriteAllowed(userId, "当前角色仅可查看打卡历史记录");
if (file == null || file.isEmpty()) {
throw new BusinessException("请先选择现场照片");
}
@ -283,6 +291,17 @@ public class WorkServiceImpl implements WorkService {
}
}
private void ensureWorkWriteAllowed(Long userId, String deniedMessage) {
List<String> roleCodes = profileMapper.selectUserRoleCodes(userId);
boolean onlySee = roleCodes != null && roleCodes.stream()
.filter(roleCode -> roleCode != null && !roleCode.isBlank())
.map(roleCode -> roleCode.trim().toLowerCase())
.anyMatch(ONLY_SEE_ROLE_CODE::equals);
if (onlySee) {
throw new BusinessException(deniedMessage);
}
}
private List<WorkHistoryItemDTO> loadHistoryItems(HistoryType historyType, int fetchLimit) {
List<WorkHistoryItemDTO> historyItems = new ArrayList<>();
if (historyType == null || historyType == HistoryType.CHECK_IN) {

View File

@ -54,6 +54,7 @@
select
s.id,
s.owner_user_id as ownerUserId,
coalesce(nullif(u.display_name, ''), nullif(u.username, ''), '无') as owner,
'sales' as type,
coalesce(s.employee_no, '无') as employeeNo,
s.candidate_name as name,
@ -90,6 +91,9 @@
coalesce(to_char(s.expected_join_date, 'YYYY-MM-DD'), '无') as expectedJoinDate,
coalesce(s.remark, '无') as notes
from crm_sales_expansion s
left join sys_user u
on u.user_id = s.owner_user_id
and u.is_deleted = 0
left join sys_dict_item office_dict
on office_dict.type_code = 'tz_bsc'
and office_dict.item_value = s.office_name
@ -117,6 +121,7 @@
select
c.id,
c.owner_user_id as ownerUserId,
coalesce(nullif(u.display_name, ''), nullif(u.username, ''), '无') as owner,
'channel' as type,
coalesce(c.channel_code, '') as channelCode,
c.channel_name as name,
@ -165,6 +170,9 @@
coalesce(to_char(c.expected_sign_date, 'YYYY-MM-DD'), '无') as expectedSignDate,
coalesce(c.remark, '无') as notes
from crm_channel_expansion c
left join sys_user u
on u.user_id = c.owner_user_id
and u.is_deleted = 0
left join lateral (
select
contact_name,

View File

@ -8,8 +8,11 @@ import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unis.crm.common.BusinessException;
import com.unis.crm.dto.work.CreateWorkCheckInRequest;
import com.unis.crm.dto.work.CreateWorkDailyReportRequest;
import com.unis.crm.dto.work.WorkHistoryItemDTO;
import com.unis.crm.dto.work.WorkHistoryPageDTO;
import com.unis.crm.mapper.ProfileMapper;
import com.unis.crm.mapper.WorkMapper;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
@ -17,6 +20,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
@ExtendWith(MockitoExtension.class)
class WorkServiceImplTest {
@ -24,11 +28,14 @@ class WorkServiceImplTest {
@Mock
private WorkMapper workMapper;
@Mock
private ProfileMapper profileMapper;
private WorkServiceImpl workService;
@BeforeEach
void setUp() {
workService = new WorkServiceImpl(workMapper, new ObjectMapper(), "build/test-uploads", "");
workService = new WorkServiceImpl(workMapper, profileMapper, new ObjectMapper(), "build/test-uploads", "");
}
@Test
@ -91,6 +98,43 @@ class WorkServiceImplTest {
assertEquals("历史记录类型不支持", exception.getMessage());
}
@Test
void saveCheckIn_shouldRejectOnlySeeRole() {
when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("only_see", "sales"));
BusinessException exception = assertThrows(
BusinessException.class,
() -> workService.saveCheckIn(17L, new CreateWorkCheckInRequest()));
assertEquals("当前角色仅可查看打卡历史记录", exception.getMessage());
verify(workMapper, never()).insertCheckIn(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any());
}
@Test
void saveDailyReport_shouldRejectOnlySeeRole() {
when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("only_see", "sales"));
BusinessException exception = assertThrows(
BusinessException.class,
() -> workService.saveDailyReport(17L, new CreateWorkDailyReportRequest()));
assertEquals("当前角色仅可查看日报历史记录", exception.getMessage());
verify(workMapper, never()).insertDailyReport(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any());
}
@Test
void uploadCheckInPhoto_shouldRejectOnlySeeRole() {
when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("only_see"));
BusinessException exception = assertThrows(
BusinessException.class,
() -> workService.uploadCheckInPhoto(
17L,
new MockMultipartFile("file", "photo.jpg", "image/jpeg", new byte[] {1, 2, 3})));
assertEquals("当前角色仅可查看打卡历史记录", exception.getMessage());
}
private WorkHistoryItemDTO historyItem(Long id, String type, String date, String time, String content) {
WorkHistoryItemDTO item = new WorkHistoryItemDTO();
item.setId(id);

View File

@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { Check, ChevronDown, X } from "lucide-react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
export type AdaptiveSelectOption = {
@ -77,7 +78,9 @@ export function AdaptiveSelect({
const [open, setOpen] = useState(false);
const [searchKeyword, setSearchKeyword] = useState("");
const containerRef = useRef<HTMLDivElement | null>(null);
const desktopDropdownRef = useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobileViewport();
const [desktopDropdownStyle, setDesktopDropdownStyle] = useState<{ top: number; left: number; width: number } | null>(null);
const selectedValues = multiple
? Array.isArray(value) ? value : []
: typeof value === "string" && value ? [value] : [];
@ -96,13 +99,27 @@ export function AdaptiveSelect({
})
: options;
const updateDesktopDropdownPosition = useCallback(() => {
if (isMobile || !open || !containerRef.current) {
return;
}
const rect = containerRef.current.getBoundingClientRect();
setDesktopDropdownStyle({
top: rect.bottom + 8,
left: rect.left,
width: rect.width,
});
}, [isMobile, open]);
useEffect(() => {
if (!open || isMobile) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
const targetNode = event.target as Node;
if (!containerRef.current?.contains(targetNode) && !desktopDropdownRef.current?.contains(targetNode)) {
setOpen(false);
}
};
@ -121,6 +138,24 @@ export function AdaptiveSelect({
};
}, [isMobile, open]);
useEffect(() => {
if (!open || isMobile) {
setDesktopDropdownStyle(null);
return;
}
updateDesktopDropdownPosition();
const handleViewportChange = () => updateDesktopDropdownPosition();
window.addEventListener("resize", handleViewportChange);
window.addEventListener("scroll", handleViewportChange, true);
return () => {
window.removeEventListener("resize", handleViewportChange);
window.removeEventListener("scroll", handleViewportChange, true);
};
}, [isMobile, open, updateDesktopDropdownPosition]);
useEffect(() => {
if (!open || !isMobile) {
return;
@ -199,33 +234,43 @@ export function AdaptiveSelect({
</button>
<AnimatePresence>
{open && !isMobile ? (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
className="absolute z-30 mt-2 w-full rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
{searchable ? (
<div className="mb-2">
<input
type="text"
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
placeholder={searchPlaceholder}
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100"
/>
</div>
) : null}
<div className="max-h-72 space-y-1 overflow-y-auto pr-1">
{visibleOptions.length > 0 ? visibleOptions.map(renderOption) : (
<div className="rounded-xl px-3 py-6 text-center text-sm text-slate-500 dark:text-slate-400">
{open && !isMobile && desktopDropdownStyle && typeof document !== "undefined"
? createPortal(
<motion.div
ref={desktopDropdownRef}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
style={{
position: "fixed",
top: desktopDropdownStyle.top,
left: desktopDropdownStyle.left,
width: desktopDropdownStyle.width,
}}
className="z-[160] rounded-2xl border border-slate-200 bg-white p-2 shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
{searchable ? (
<div className="mb-2">
<input
type="text"
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
placeholder={searchPlaceholder}
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100"
/>
</div>
) : null}
<div className="max-h-72 space-y-1 overflow-y-auto pr-1">
{visibleOptions.length > 0 ? visibleOptions.map(renderOption) : (
<div className="rounded-xl px-3 py-6 text-center text-sm text-slate-500 dark:text-slate-400">
</div>
)}
</div>
)}
</div>
</motion.div>
) : null}
</motion.div>,
document.body,
)
: null}
</AnimatePresence>
<AnimatePresence>

View File

@ -349,6 +349,7 @@ export interface ExpansionFollowUp {
export interface SalesExpansionItem {
id: number;
ownerUserId?: number;
owner?: string;
type: "sales";
employeeNo?: string;
name?: string;
@ -385,6 +386,7 @@ export interface RelatedProjectSummary {
export interface ChannelExpansionItem {
id: number;
ownerUserId?: number;
owner?: string;
type: "channel";
channelCode?: string;
name?: string;

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { Search, Plus, Download, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar, ChevronRight } from "lucide-react";
import { Search, Plus, Download, MapPin, Building2, User, Phone, X, Clock, FileText, Calendar } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { useLocation } from "react-router-dom";
import {
@ -54,8 +54,6 @@ type ChannelField =
| "channelAttributeCustom"
| "internalAttribute"
| "contacts";
const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold";
function createEmptyChannelContact(): ChannelExpansionContact {
return {
name: "",
@ -192,6 +190,7 @@ function buildSalesExportHeaders(items: SalesExpansionItem[]) {
const headers = [
"工号",
"姓名",
"创建人",
"联系方式",
"代表处 / 办事处",
"所属部门",
@ -218,6 +217,7 @@ function buildSalesExportData(items: SalesExpansionItem[]) {
const row = [
normalizeExportText(item.employeeNo),
normalizeExportText(item.name),
normalizeExportText(item.owner),
normalizeExportText(item.phone),
normalizeExportText(item.officeName),
normalizeExportText(item.dept),
@ -249,6 +249,7 @@ function buildChannelExportHeaders(items: ChannelExpansionItem[]) {
const headers = [
"编码",
"渠道名称",
"创建人",
"省份",
"市",
"办公地址",
@ -285,6 +286,7 @@ function buildChannelExportData(items: ChannelExpansionItem[]) {
const row = [
normalizeExportText(item.channelCode),
normalizeExportText(item.name),
normalizeExportText(item.owner),
normalizeExportText(item.province),
normalizeExportText(item.city),
normalizeExportText(item.officeAddress),
@ -1698,25 +1700,25 @@ export default function Expansion() {
{isOwnedByCurrentUser ? (
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
) : null}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.name || "无"}</h3>
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">{item.officeName || "无"} · {item.dept || "无"} · {item.title || "无"}</p>
</div>
<div className="shrink-0 flex items-center gap-2">
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
isOwnedByCurrentUser
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{isOwnedByCurrentUser ? "我的" : "只读"}
</span>
<span className={`crm-pill shrink-0 ${item.active ? "crm-pill-emerald" : "crm-pill-neutral"}`}>
{item.active ? "在职" : "离职"}
</span>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none",
isOwnedByCurrentUser
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{isOwnedByCurrentUser ? "我的" : "只读"}
</span>
<span className={`crm-pill shrink-0 ${item.active ? "crm-pill-emerald" : "crm-pill-neutral"}`}>
{item.active ? "在职" : "离职"}
</span>
</div>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-y-3 text-xs sm:grid-cols-2 sm:text-sm">
@ -1728,12 +1730,10 @@ export default function Expansion() {
<span className="text-slate-400 dark:text-slate-500">:</span>
{formatRelatedProjectAmount(item.relatedProjects)}
</div>
</div>
<div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50">
<button type="button" className={`${detailBadgeClass} px-2 py-0.5 text-[10px] sm:px-2.5 sm:py-1 sm:text-[11px]`}>
<ChevronRight className="crm-icon-sm" />
</button>
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
<span className="font-medium text-slate-900 dark:text-white">{item.owner || "无"}</span>
</div>
</div>
</motion.div>
);
@ -1759,27 +1759,27 @@ export default function Expansion() {
{isOwnedByCurrentUser ? (
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
) : null}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<h3 className="break-anywhere text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.name || "无"}</h3>
<p className="break-anywhere mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">
{item.province || "无"} · {item.city || "无"} · {item.certificationLevel || "无"}
</p>
</div>
<div className="shrink-0 flex items-center gap-2">
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
isOwnedByCurrentUser
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{isOwnedByCurrentUser ? "我的" : "只读"}
</span>
<span className={`crm-pill shrink-0 ${item.intent === "高" ? "crm-pill-rose" : item.intent === "中" ? "crm-pill-amber" : "crm-pill-neutral"}`}>
{item.intent ? `${item.intent}意向` : "未评估"}
</span>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none",
isOwnedByCurrentUser
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{isOwnedByCurrentUser ? "我的" : "只读"}
</span>
<span className={`crm-pill shrink-0 ${item.intent === "高" ? "crm-pill-rose" : item.intent === "中" ? "crm-pill-amber" : "crm-pill-neutral"}`}>
{item.intent ? `${item.intent}意向` : "未评估"}
</span>
</div>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-y-3 text-xs sm:grid-cols-2 sm:text-sm">
@ -1791,12 +1791,10 @@ export default function Expansion() {
<span className="text-slate-400 dark:text-slate-500">:</span>
{formatRelatedProjectAmount(item.relatedProjects)}
</div>
</div>
<div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50">
<button type="button" className={detailBadgeClass}>
<ChevronRight className="crm-icon-sm" />
</button>
<div className="flex items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="text-slate-400 dark:text-slate-500">:</span>
<span className="font-medium text-slate-900 dark:text-white">{item.owner || "无"}</span>
</div>
</div>
</motion.div>
);

View File

@ -1,13 +1,11 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Search, Plus, Download, ChevronRight, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
import { Search, Plus, Download, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOmsPreSalesOptions, getOpportunityOverview, getStoredCurrentUserId, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OmsPreSalesOption, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type PushOpportunityToOmsPayload, type SalesExpansionItem } from "@/lib/auth";
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
import { cn } from "@/lib/utils";
const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold";
const CONFIDENCE_OPTIONS = [
{ value: "A", label: "A" },
{ value: "B", label: "B" },
@ -1160,6 +1158,7 @@ export default function Opportunities() {
const headers = [
"项目编号",
"项目名称",
"创建人",
"项目地",
"最终用户",
"建设类型",
@ -1207,6 +1206,7 @@ export default function Opportunities() {
worksheet.addRow([
normalizeOpportunityExportText(item.code),
normalizeOpportunityExportText(item.name),
normalizeOpportunityExportText(item.owner),
normalizeOpportunityExportText(item.projectLocation),
normalizeOpportunityExportText(item.client),
normalizeOpportunityExportText(item.type || "新建"),
@ -1599,38 +1599,38 @@ export default function Opportunities() {
{isOwnedByCurrentUser ? (
<div className="pointer-events-none absolute inset-x-5 top-0 h-1.5 rounded-b-full bg-gradient-to-r from-violet-400/85 via-fuchsia-400/70 to-indigo-400/85 shadow-[0_6px_16px_rgba(124,58,237,0.18)] dark:from-violet-400/70 dark:via-fuchsia-400/55 dark:to-indigo-400/70" />
) : null}
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<h3 className="truncate text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{opp.name || "未命名商机"}</h3>
<p className="mt-1 truncate text-xs leading-5 text-slate-500 dark:text-slate-400 sm:text-sm">{opp.code || "待生成"}</p>
</div>
<div className="shrink-0 flex flex-wrap items-center justify-end gap-2 pl-2">
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
isOwnedByCurrentUser
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{isOwnedByCurrentUser ? "我的" : "只读"}
</span>
<span className={getConfidenceBadgeClass(opp.confidence)}>
{getConfidenceLabel(opp.confidence)}
</span>
<span className="crm-pill crm-pill-neutral">
{opp.stage || "初步沟通"}
</span>
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold",
opp.pushedToOms
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{opp.pushedToOms ? "已推送 OMS" : "未推送 OMS"}
</span>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none",
isOwnedByCurrentUser
? "border-emerald-200 bg-emerald-50 text-emerald-600 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{isOwnedByCurrentUser ? "我的" : "只读"}
</span>
<span className={getConfidenceBadgeClass(opp.confidence)}>
{getConfidenceLabel(opp.confidence)}
</span>
<span className="crm-pill crm-pill-neutral">
{opp.stage || "初步沟通"}
</span>
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[11px] font-semibold leading-none",
opp.pushedToOms
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300",
)}
>
{opp.pushedToOms ? "已推送 OMS" : "未推送 OMS"}
</span>
</div>
</div>
</div>
@ -1651,13 +1651,12 @@ export default function Opportunities() {
<span className="shrink-0 text-slate-400 dark:text-slate-500">:</span>
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.nextPlan || "暂无回写规划"}</span>
</div>
<div className="flex min-w-0 items-center gap-2 text-slate-600 dark:text-slate-300">
<span className="shrink-0 text-slate-400 dark:text-slate-500">:</span>
<span className="min-w-0 flex-1 truncate font-medium text-slate-900 dark:text-white">{opp.owner || "无"}</span>
</div>
</div>
<div className="mt-4 flex justify-end border-t border-slate-50 pt-3 dark:border-slate-800/50">
<button type="button" className={detailBadgeClass}>
<ChevronRight className="crm-icon-sm ml-0.5" />
</button>
</div>
</motion.div>
);
})

View File

@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, ty
import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { motion } from "motion/react";
import { ArrowUp, AtSign, Camera, CheckCircle2, FileText, ListTodo, MapPin, NotebookPen, Plus, RefreshCw, Search, Send, Trash2, X } from "lucide-react";
import { ArrowUp, AtSign, Camera, CheckCircle2, ChevronDown, FileText, ListTodo, MapPin, NotebookPen, Plus, RefreshCw, Search, Send, Trash2, X } from "lucide-react";
import { flushSync } from "react-dom";
import { Link, Navigate, useLocation } from "react-router-dom";
import {
@ -164,6 +164,10 @@ export default function Work() {
const [historyHasMore, setHistoryHasMore] = useState(false);
const [historyPage, setHistoryPage] = useState(1);
const [showHistoryBackToTop, setShowHistoryBackToTop] = useState(false);
const [historyPresenterFilter, setHistoryPresenterFilter] = useState("all");
const [historyPresenterKeyword, setHistoryPresenterKeyword] = useState("");
const [historyPresenterPickerOpen, setHistoryPresenterPickerOpen] = useState(false);
const [expandedHistoryGroupKey, setExpandedHistoryGroupKey] = useState<string | null>(null);
const [reportStatus, setReportStatus] = useState<string>();
const [currentUser, setCurrentUser] = useState<UserProfile | null>(() => readStoredUserProfile());
const [profileOverview, setProfileOverview] = useState<ProfileOverview | null>(null);
@ -198,6 +202,52 @@ export default function Work() {
return options.filter((option) => option.label.toLowerCase().includes(keyword));
}, [objectPicker, salesOptions, channelOptions, opportunityOptions]);
const historyPresenterOptions = useMemo(() => {
const presenterMap = new Map<string, { label: string; count: number }>();
for (const item of historyData) {
const presenter = extractHistoryPresenter(item.content);
const normalizedName = presenter.name.trim();
if (!presenter.label || !normalizedName) {
continue;
}
const existing = presenterMap.get(normalizedName);
if (existing) {
existing.count += 1;
continue;
}
presenterMap.set(normalizedName, {
label: `${presenter.label}${normalizedName}`,
count: 1,
});
}
return Array.from(presenterMap.entries())
.map(([value, meta]) => ({ value, label: meta.label, count: meta.count }))
.sort((left, right) => right.count - left.count || left.value.localeCompare(right.value, "zh-CN"));
}, [historyData]);
const filteredHistoryData = useMemo(() => {
if (historyPresenterFilter === "all") {
return historyData;
}
return historyData.filter((item) => extractHistoryPresenter(item.content).name.trim() === historyPresenterFilter);
}, [historyData, historyPresenterFilter]);
const historyGroups = useMemo(() => {
const groups: Array<{ key: string; date: string; items: WorkHistoryItem[] }> = [];
for (const item of filteredHistoryData) {
const groupDate = item.date || "未标注日期";
const lastGroup = groups[groups.length - 1];
if (lastGroup && lastGroup.date === groupDate) {
lastGroup.items.push(item);
continue;
}
const groupSeed = item.id != null ? `${item.type}-${item.id}` : `${item.type}-${groups.length}`;
groups.push({ key: `${groupDate}-${groupSeed}`, date: groupDate, items: [item] });
}
return groups;
}, [filteredHistoryData]);
const loadReportTargets = useCallback(async () => {
if (reportTargetsLoaded || reportTargetsLoading) {
return;
@ -296,6 +346,36 @@ export default function Work() {
setHistoryDetailItem(nextItem);
}, [historyData, historyDetailItem]);
useEffect(() => {
if (historyPresenterFilter === "all") {
return;
}
const stillExists = historyPresenterOptions.some((option) => option.value === historyPresenterFilter);
if (!stillExists) {
setHistoryPresenterFilter("all");
}
}, [historyPresenterFilter, historyPresenterOptions]);
useEffect(() => {
if (!historyPresenterPickerOpen && historyPresenterKeyword) {
setHistoryPresenterKeyword("");
}
}, [historyPresenterKeyword, historyPresenterPickerOpen]);
useEffect(() => {
setExpandedHistoryGroupKey(null);
}, [historySection, historyPresenterFilter]);
useEffect(() => {
if (!expandedHistoryGroupKey) {
return;
}
const stillExists = historyGroups.some((group) => group.key === expandedHistoryGroupKey);
if (!stillExists) {
setExpandedHistoryGroupKey(null);
}
}, [expandedHistoryGroupKey, historyGroups]);
useEffect(() => {
let cancelled = false;
@ -888,21 +968,61 @@ export default function Work() {
>
{historyLoading && historyData.length === 0 ? <WorkHistorySkeleton /> : null}
{historyData.map((item, index) => (
<div key={`${item.type}-${item.id}-${index}`}>
<HistoryCard
item={item}
index={index}
onOpen={() => setHistoryDetailItem(item)}
onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })}
disableMobileMotion={disableMobileMotion}
/>
</div>
))}
{!historyLoading || historyData.length > 0 ? (
<HistoryToolbar
section={historySection}
presenterFilter={historyPresenterFilter}
presenterOptions={historyPresenterOptions}
filteredCount={filteredHistoryData.length}
totalCount={historyData.length}
onOpenPresenterPicker={() => setHistoryPresenterPickerOpen(true)}
onPresenterFilterChange={(value) => {
setHistoryPresenterFilter(value);
setHistoryPresenterPickerOpen(false);
}}
/>
) : null}
{!historyLoading && historyData.length === 0 ? (
{historyGroups.map((group) => {
const groupExpanded = historyPresenterFilter !== "all" || expandedHistoryGroupKey === group.key;
const canToggleGroup = historyPresenterFilter === "all";
return (
<div key={group.key} className="space-y-3">
<HistoryDateGroup
date={group.date}
count={group.items.length}
expanded={groupExpanded}
canToggle={canToggleGroup}
onToggle={() => {
if (!canToggleGroup) {
return;
}
setExpandedHistoryGroupKey((current) => (current === group.key ? null : group.key));
}}
/>
{groupExpanded
? group.items.map((item, index) => (
<div key={`${group.date}-${item.type}-${item.id}-${index}`}>
<HistoryCard
item={item}
index={index}
onOpen={() => setHistoryDetailItem(item)}
onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })}
disableMobileMotion={disableMobileMotion}
/>
</div>
))
: null}
</div>
);
})}
{!historyLoading && filteredHistoryData.length === 0 ? (
<div className="crm-empty-panel">
{getHistoryLabelBySection(historySection)}
{historyPresenterFilter === "all"
? `当前没有${getHistoryLabelBySection(historySection)}记录`
: `当前筛选条件下没有${getHistoryLabelBySection(historySection)}记录`}
</div>
) : null}
@ -1041,6 +1161,21 @@ export default function Work() {
/>
) : null}
{historyPresenterPickerOpen ? (
<HistoryPresenterPickerModal
section={historySection}
presenterFilter={historyPresenterFilter}
presenterKeyword={historyPresenterKeyword}
presenterOptions={historyPresenterOptions}
onClose={() => setHistoryPresenterPickerOpen(false)}
onPresenterFilterChange={(value) => {
setHistoryPresenterFilter(value);
setHistoryPresenterPickerOpen(false);
}}
onPresenterKeywordChange={setHistoryPresenterKeyword}
/>
) : null}
{previewPhoto ? (
<PhotoPreviewModal
url={previewPhoto.url}
@ -1463,6 +1598,226 @@ function WorkHistorySkeleton() {
);
}
function HistoryToolbar({
section,
presenterFilter,
presenterOptions,
filteredCount,
totalCount,
onOpenPresenterPicker,
onPresenterFilterChange,
}: {
section: WorkSection;
presenterFilter: string;
presenterOptions: Array<{ value: string; label: string; count: number }>;
filteredCount: number;
totalCount: number;
onOpenPresenterPicker: () => void;
onPresenterFilterChange: (value: string) => void;
}) {
const currentPresenterLabel = presenterFilter === "all"
? "全部人员"
: presenterOptions.find((option) => option.value === presenterFilter)?.value ?? presenterFilter;
return (
<div className="overflow-hidden rounded-2xl border border-slate-200/80 bg-gradient-to-br from-white via-slate-50/92 to-slate-100/85 shadow-sm dark:border-slate-800 dark:from-slate-900 dark:via-slate-900/96 dark:to-slate-950">
<div className="flex flex-col gap-2.5 p-2.5 sm:gap-3 sm:p-3">
<div className="rounded-2xl border border-white/80 bg-white/95 p-2 shadow-sm dark:border-slate-700 dark:bg-slate-900/80 sm:p-2.5">
<div className="mb-1.5 flex items-center justify-between gap-3 px-1">
<span className="flex items-center gap-1.5 text-xs font-medium text-slate-500 dark:text-slate-400">
<Search className="h-3.5 w-3.5" />
</span>
<span className="hidden text-[11px] text-slate-400 dark:text-slate-500 sm:inline">{presenterOptions.length} </span>
</div>
<button
type="button"
onClick={onOpenPresenterPicker}
className="flex w-full items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2.5 text-left transition-colors hover:border-violet-300 hover:bg-white dark:border-slate-700 dark:bg-slate-800/70 dark:hover:border-violet-500/40 dark:hover:bg-slate-800"
>
<p className="min-w-0 flex-1 truncate text-sm font-semibold text-slate-900 dark:text-white">{currentPresenterLabel}</p>
<span className="shrink-0 rounded-full bg-violet-50 px-2 py-1 text-[11px] font-medium text-violet-600 dark:bg-violet-500/10 dark:text-violet-300">
</span>
</button>
</div>
</div>
</div>
);
}
function HistoryPresenterPickerModal({
section,
presenterFilter,
presenterKeyword,
presenterOptions,
onClose,
onPresenterFilterChange,
onPresenterKeywordChange,
}: {
section: WorkSection;
presenterFilter: string;
presenterKeyword: string;
presenterOptions: Array<{ value: string; label: string; count: number }>;
onClose: () => void;
onPresenterFilterChange: (value: string) => void;
onPresenterKeywordChange: (value: string) => void;
}) {
const isMobileViewport = useIsMobileViewport();
const normalizedKeyword = presenterKeyword.trim().toLowerCase();
const visiblePresenterOptions = normalizedKeyword
? presenterOptions.filter((option) => option.label.toLowerCase().includes(normalizedKeyword) || option.value.toLowerCase().includes(normalizedKeyword))
: presenterOptions;
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, []);
return (
<div className="fixed inset-0 z-[92]">
<button
type="button"
onClick={onClose}
className="absolute inset-0 bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
aria-label="关闭人员筛选"
/>
<div
className={cn(
"absolute flex flex-col overflow-hidden border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900",
isMobileViewport
? "inset-x-0 bottom-0 max-h-[86dvh] rounded-t-3xl"
: "left-1/2 top-1/2 h-[min(72vh,720px)] w-[min(520px,calc(100vw-32px))] -translate-x-1/2 -translate-y-1/2 rounded-3xl",
)}
>
<div className="shrink-0 border-b border-slate-100 px-5 py-4 dark:border-slate-800">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="text-base font-semibold text-slate-900 dark:text-white">{getHistoryLabelBySection(section)}</h3>
<p className="mt-1 text-xs leading-5 text-slate-500 dark:text-slate-400"></p>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 dark:hover:bg-slate-800"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="relative mt-4">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 dark:text-slate-500" />
<input
autoFocus
type="text"
value={presenterKeyword}
onChange={(event) => onPresenterKeywordChange(event.target.value)}
placeholder="搜索打卡人或提交人"
className="h-11 w-full rounded-2xl border border-slate-200 bg-slate-50 pl-10 pr-3 text-sm text-slate-900 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-white"
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<div className="space-y-2">
<button
type="button"
onClick={() => onPresenterFilterChange("all")}
className={cn(
"flex w-full items-center justify-between rounded-2xl border px-4 py-3 text-left transition-colors",
presenterFilter === "all"
? "border-violet-500 bg-violet-50 text-violet-700 dark:border-violet-500/50 dark:bg-violet-500/10 dark:text-violet-200"
: "border-slate-200 bg-white text-slate-700 hover:border-violet-200 hover:bg-violet-50/70 dark:border-slate-800 dark:bg-slate-900/40 dark:text-slate-200 dark:hover:border-violet-500/30 dark:hover:bg-slate-800",
)}
>
<div>
<p className="text-sm font-semibold"></p>
<p className="mt-1 text-xs opacity-70">{getHistoryLabelBySection(section)}</p>
</div>
{presenterFilter === "all" ? <CheckCircle2 className="h-4 w-4 shrink-0" /> : null}
</button>
{visiblePresenterOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onPresenterFilterChange(option.value)}
className={cn(
"flex w-full items-center justify-between gap-3 rounded-2xl border px-4 py-3 text-left transition-colors",
presenterFilter === option.value
? "border-violet-500 bg-violet-50 text-violet-700 dark:border-violet-500/50 dark:bg-violet-500/10 dark:text-violet-200"
: "border-slate-200 bg-white text-slate-700 hover:border-violet-200 hover:bg-violet-50/70 dark:border-slate-800 dark:bg-slate-900/40 dark:text-slate-200 dark:hover:border-violet-500/30 dark:hover:bg-slate-800",
)}
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{option.value}</p>
<p className="mt-1 text-xs opacity-70">{option.count} </p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-300">
{option.count}
</span>
{presenterFilter === option.value ? <CheckCircle2 className="h-4 w-4" /> : null}
</div>
</button>
))}
{normalizedKeyword && visiblePresenterOptions.length === 0 ? (
<div className="crm-empty-panel px-4 py-10"></div>
) : null}
</div>
</div>
</div>
</div>
);
}
function HistoryDateGroup({
date,
count,
expanded,
canToggle,
onToggle,
}: {
date: string;
count: number;
expanded: boolean;
canToggle: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
onClick={onToggle}
className={cn(
"sticky top-0 z-[1] flex w-full items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50/96 px-3 py-2 text-left shadow-sm backdrop-blur transition-colors dark:border-slate-800 dark:bg-slate-900/96",
canToggle
? "hover:border-violet-200 hover:bg-white dark:hover:border-violet-500/30 dark:hover:bg-slate-900"
: "cursor-default",
)}
>
<div className="min-w-0">
<p className="text-sm font-semibold text-slate-900 dark:text-white">{formatHistoryGroupDate(date)}</p>
<p className="text-[11px] text-slate-500 dark:text-slate-400">
{date === "未标注日期" ? "未补充日期信息" : `${count} 条记录`}
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-slate-500 shadow-sm dark:bg-slate-800 dark:text-slate-300">
{count}
</span>
{canToggle ? (
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-white text-slate-400 shadow-sm dark:bg-slate-800 dark:text-slate-300">
<ChevronDown className={cn("h-4 w-4 transition-transform", expanded ? "rotate-180" : "")} />
</span>
) : null}
</div>
</button>
);
}
function HistoryCard({
item,
index,
@ -2131,18 +2486,25 @@ function HistoryDetailModal({
: "border-emerald-200 bg-emerald-50/90 text-emerald-900 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100";
return (
<div className="fixed inset-0 z-[95]">
<button
<>
<motion.button
type="button"
onClick={onClose}
className={cn("absolute inset-0 bg-slate-900/45", !disableMobileMotion && "backdrop-blur-sm")}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className={cn("fixed inset-0 z-[95] bg-slate-900/45", !disableMobileMotion && "backdrop-blur-sm")}
aria-label="关闭历史详情"
/>
<div className="absolute inset-x-0 bottom-0 max-h-[86dvh] overflow-hidden rounded-t-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900 md:inset-x-auto md:left-1/2 md:top-1/2 md:bottom-auto md:w-[min(760px,88vw)] md:max-h-[80vh] md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-3xl">
<div className="flex items-start justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800 md:px-6">
<div>
<h3 className="text-base font-semibold text-slate-900 dark:text-white">{item.type || "历史记录详情"}</h3>
<p className="crm-field-note mt-1">{[item.date, item.time].filter(Boolean).join(" ") || "无时间信息"}</p>
<motion.div
initial={{ x: "100%", y: 0 }}
animate={{ x: 0, y: 0 }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className="fixed inset-x-0 bottom-0 z-[96] flex h-[90dvh] w-full flex-col rounded-t-3xl border border-slate-200 bg-white shadow-2xl transition-opacity dark:border-slate-800 dark:bg-slate-900 sm:inset-y-0 sm:right-0 sm:left-auto sm:h-full sm:w-[min(760px,92vw)] sm:max-w-none sm:rounded-none sm:rounded-l-3xl sm:border-l lg:w-[min(820px,88vw)]"
>
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">
<div className="flex items-center gap-3">
<div className="h-1.5 w-10 rounded-full bg-slate-200 sm:hidden dark:bg-slate-700" />
<h3 className="text-base font-semibold text-slate-900 dark:text-white sm:text-lg">{item.type || "历史记录详情"}</h3>
</div>
<button
type="button"
@ -2154,8 +2516,14 @@ function HistoryDetailModal({
</button>
</div>
<div className="max-h-[calc(86dvh-84px)] overflow-y-auto px-5 py-4 pb-[calc(env(safe-area-inset-bottom)+20px)] md:max-h-[calc(80vh-84px)] md:px-6">
<div className="flex flex-wrap items-center gap-2">
<div className="flex-1 overflow-y-auto px-4 py-4 pb-[calc(env(safe-area-inset-bottom)+20px)] sm:px-6 sm:py-5">
<div>
<p className="text-xs font-medium text-slate-400 dark:text-slate-500">
{[item.date, item.time].filter(Boolean).join(" ") || "无时间信息"}
</p>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{item.status ? (
<span className={cn(
"rounded-full px-2.5 py-1 text-xs font-medium",
@ -2224,8 +2592,8 @@ function HistoryDetailModal({
</section>
) : null}
</div>
</div>
</div>
</motion.div>
</>
);
}
@ -2326,6 +2694,18 @@ function extractHistoryPresenter(content: string | undefined) {
};
}
function formatHistoryGroupDate(date: string) {
if (!date || date === "未标注日期") {
return "未标注日期";
}
const parsed = new Date(`${date}T00:00:00`);
if (Number.isNaN(parsed.getTime())) {
return date;
}
return format(parsed, "M月d日 EEEE", { locale: zhCN });
}
function SummaryCell({ label, value }: { label: string; value: string }) {
return (
<div className="bg-white px-4 py-3 dark:bg-slate-900/50">