企业微信内部定位与密码缓存
parent
b574da0a7c
commit
42c21cc4fc
|
|
@ -1,4 +1,3 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="MavenProjectsManager">
|
<component name="MavenProjectsManager">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.unis.crm.controller;
|
package com.unis.crm.controller;
|
||||||
|
|
||||||
import com.unis.crm.common.ApiResponse;
|
import com.unis.crm.common.ApiResponse;
|
||||||
|
import com.unis.crm.dto.wecom.WecomJsSdkConfigDTO;
|
||||||
import com.unis.crm.dto.wecom.WecomSsoExchangeRequest;
|
import com.unis.crm.dto.wecom.WecomSsoExchangeRequest;
|
||||||
import com.unis.crm.service.WecomSsoService;
|
import com.unis.crm.service.WecomSsoService;
|
||||||
import com.unisbase.dto.TokenResponse;
|
import com.unisbase.dto.TokenResponse;
|
||||||
|
|
@ -43,4 +44,9 @@ public class WecomSsoController {
|
||||||
public ApiResponse<TokenResponse> exchange(@Valid @RequestBody WecomSsoExchangeRequest request) {
|
public ApiResponse<TokenResponse> exchange(@Valid @RequestBody WecomSsoExchangeRequest request) {
|
||||||
return ApiResponse.success(wecomSsoService.exchangeTicket(request.getTicket()));
|
return ApiResponse.success(wecomSsoService.exchangeTicket(request.getTicket()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/js-sdk-config")
|
||||||
|
public ApiResponse<WecomJsSdkConfigDTO> getJsSdkConfig(@RequestParam("url") String url) {
|
||||||
|
return ApiResponse.success(wecomSsoService.buildJsSdkConfig(url));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package com.unis.crm.dto.wecom;
|
||||||
|
|
||||||
|
public class WecomJsSdkConfigDTO {
|
||||||
|
|
||||||
|
private String corpId;
|
||||||
|
private long timestamp;
|
||||||
|
private String nonceStr;
|
||||||
|
private String signature;
|
||||||
|
|
||||||
|
public String getCorpId() {
|
||||||
|
return corpId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCorpId(String corpId) {
|
||||||
|
this.corpId = corpId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTimestamp() {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimestamp(long timestamp) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNonceStr() {
|
||||||
|
return nonceStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNonceStr(String nonceStr) {
|
||||||
|
this.nonceStr = nonceStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSignature() {
|
||||||
|
return signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSignature(String signature) {
|
||||||
|
this.signature = signature;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.unis.crm.service;
|
package com.unis.crm.service;
|
||||||
|
|
||||||
|
import com.unis.crm.dto.wecom.WecomJsSdkConfigDTO;
|
||||||
import com.unisbase.dto.TokenResponse;
|
import com.unisbase.dto.TokenResponse;
|
||||||
|
|
||||||
public interface WecomSsoService {
|
public interface WecomSsoService {
|
||||||
|
|
@ -9,4 +10,6 @@ public interface WecomSsoService {
|
||||||
String handleCallback(String code, String state);
|
String handleCallback(String code, String state);
|
||||||
|
|
||||||
TokenResponse exchangeTicket(String ticket);
|
TokenResponse exchangeTicket(String ticket);
|
||||||
|
|
||||||
|
WecomJsSdkConfigDTO buildJsSdkConfig(String url);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.unis.crm.common.BusinessException;
|
import com.unis.crm.common.BusinessException;
|
||||||
import com.unis.crm.config.WecomProperties;
|
import com.unis.crm.config.WecomProperties;
|
||||||
|
import com.unis.crm.dto.wecom.WecomJsSdkConfigDTO;
|
||||||
import com.unis.crm.service.WecomSsoService;
|
import com.unis.crm.service.WecomSsoService;
|
||||||
import com.unisbase.auth.JwtTokenProvider;
|
import com.unisbase.auth.JwtTokenProvider;
|
||||||
import com.unisbase.common.RedisKeys;
|
import com.unisbase.common.RedisKeys;
|
||||||
|
|
@ -14,7 +15,10 @@ import com.unisbase.mapper.SysUserMapper;
|
||||||
import com.unisbase.service.AuthVersionService;
|
import com.unisbase.service.AuthVersionService;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -33,7 +37,9 @@ public class WecomSsoServiceImpl implements WecomSsoService {
|
||||||
private static final String WECOM_GET_TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken";
|
private static final String WECOM_GET_TOKEN_URL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken";
|
||||||
private static final String WECOM_GET_USER_INFO_URL = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo";
|
private static final String WECOM_GET_USER_INFO_URL = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo";
|
||||||
private static final String WECOM_GET_USER_URL = "https://qyapi.weixin.qq.com/cgi-bin/user/get";
|
private static final String WECOM_GET_USER_URL = "https://qyapi.weixin.qq.com/cgi-bin/user/get";
|
||||||
|
private static final String WECOM_GET_JSAPI_TICKET_URL = "https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket";
|
||||||
private static final String ACCESS_TOKEN_CACHE_KEY = "crm:wecom:access-token";
|
private static final String ACCESS_TOKEN_CACHE_KEY = "crm:wecom:access-token";
|
||||||
|
private static final String JSAPI_TICKET_CACHE_KEY = "crm:wecom:jsapi-ticket";
|
||||||
private static final String STATE_CACHE_PREFIX = "crm:wecom:sso:state:";
|
private static final String STATE_CACHE_PREFIX = "crm:wecom:sso:state:";
|
||||||
private static final String TICKET_CACHE_PREFIX = "crm:wecom:sso:ticket:";
|
private static final String TICKET_CACHE_PREFIX = "crm:wecom:sso:ticket:";
|
||||||
|
|
||||||
|
|
@ -145,6 +151,27 @@ public class WecomSsoServiceImpl implements WecomSsoService {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public WecomJsSdkConfigDTO buildJsSdkConfig(String url) {
|
||||||
|
ensureEnabled();
|
||||||
|
validateRequiredConfig();
|
||||||
|
String normalizedUrl = normalizeJsSdkUrl(url);
|
||||||
|
String jsapiTicket = getJsApiTicket(getCorpAccessToken());
|
||||||
|
String nonceStr = UUID.randomUUID().toString().replace("-", "");
|
||||||
|
long timestamp = Instant.now().getEpochSecond();
|
||||||
|
String signature = sha1("jsapi_ticket=" + jsapiTicket
|
||||||
|
+ "&noncestr=" + nonceStr
|
||||||
|
+ "×tamp=" + timestamp
|
||||||
|
+ "&url=" + normalizedUrl);
|
||||||
|
|
||||||
|
WecomJsSdkConfigDTO dto = new WecomJsSdkConfigDTO();
|
||||||
|
dto.setCorpId(wecomProperties.getCorpId());
|
||||||
|
dto.setTimestamp(timestamp);
|
||||||
|
dto.setNonceStr(nonceStr);
|
||||||
|
dto.setSignature(signature);
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
private void ensureEnabled() {
|
private void ensureEnabled() {
|
||||||
if (!wecomProperties.isEnabled()) {
|
if (!wecomProperties.isEnabled()) {
|
||||||
throw new BusinessException("企业微信单点登录未启用");
|
throw new BusinessException("企业微信单点登录未启用");
|
||||||
|
|
@ -181,6 +208,26 @@ public class WecomSsoServiceImpl implements WecomSsoService {
|
||||||
return accessToken;
|
return accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String getJsApiTicket(String accessToken) {
|
||||||
|
String cached = stringRedisTemplate.opsForValue().get(JSAPI_TICKET_CACHE_KEY);
|
||||||
|
if (StringUtils.hasText(cached)) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode response = getJson(WECOM_GET_JSAPI_TICKET_URL
|
||||||
|
+ "?access_token=" + urlEncode(accessToken));
|
||||||
|
assertWecomSuccess(response, "获取企业微信JS-SDK票据失败");
|
||||||
|
String jsapiTicket = response.path("ticket").asText("");
|
||||||
|
long expiresIn = response.path("expires_in").asLong(7200);
|
||||||
|
if (!StringUtils.hasText(jsapiTicket)) {
|
||||||
|
throw new BusinessException("企业微信JS-SDK票据为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
long ttlSeconds = Math.max(60L, expiresIn - wecomProperties.getAccessTokenSafetySeconds());
|
||||||
|
stringRedisTemplate.opsForValue().set(JSAPI_TICKET_CACHE_KEY, jsapiTicket, Duration.ofSeconds(ttlSeconds));
|
||||||
|
return jsapiTicket;
|
||||||
|
}
|
||||||
|
|
||||||
private String getWecomUserId(String accessToken, String code) {
|
private String getWecomUserId(String accessToken, String code) {
|
||||||
JsonNode response = getJson(WECOM_GET_USER_INFO_URL
|
JsonNode response = getJson(WECOM_GET_USER_INFO_URL
|
||||||
+ "?access_token=" + urlEncode(accessToken)
|
+ "?access_token=" + urlEncode(accessToken)
|
||||||
|
|
@ -377,6 +424,32 @@ public class WecomSsoServiceImpl implements WecomSsoService {
|
||||||
return digits;
|
return digits;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalizeJsSdkUrl(String url) {
|
||||||
|
if (!StringUtils.hasText(url)) {
|
||||||
|
throw new BusinessException("企业微信JS-SDK签名地址不能为空");
|
||||||
|
}
|
||||||
|
String trimmed = url.trim();
|
||||||
|
if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
|
||||||
|
throw new BusinessException("企业微信JS-SDK签名地址格式不正确");
|
||||||
|
}
|
||||||
|
int fragmentIndex = trimmed.indexOf('#');
|
||||||
|
return fragmentIndex >= 0 ? trimmed.substring(0, fragmentIndex) : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sha1(String content) {
|
||||||
|
try {
|
||||||
|
MessageDigest messageDigest = MessageDigest.getInstance("SHA-1");
|
||||||
|
byte[] digest = messageDigest.digest(content.getBytes(StandardCharsets.UTF_8));
|
||||||
|
StringBuilder builder = new StringBuilder(digest.length * 2);
|
||||||
|
for (byte current : digest) {
|
||||||
|
builder.append(String.format("%02x", current));
|
||||||
|
}
|
||||||
|
return builder.toString();
|
||||||
|
} catch (NoSuchAlgorithmException ex) {
|
||||||
|
throw new BusinessException("企业微信JS-SDK签名生成失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String urlEncode(String value) {
|
private String urlEncode(String value) {
|
||||||
return URLEncoder.encode(defaultIfBlank(value, ""), StandardCharsets.UTF_8);
|
return URLEncoder.encode(defaultIfBlank(value, ""), StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { cn } from "@/lib/utils";
|
||||||
import { useTheme } from "./ThemeProvider";
|
import { useTheme } from "./ThemeProvider";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { getWorkOverview } from "@/lib/auth";
|
import { getWorkOverview } from "@/lib/auth";
|
||||||
|
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
|
||||||
|
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||||
|
|
||||||
type NavChildItem = {
|
type NavChildItem = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -48,6 +50,9 @@ export default function Layout() {
|
||||||
const [afterReminderTime, setAfterReminderTime] = useState(isAfterDailyReportReminderTime());
|
const [afterReminderTime, setAfterReminderTime] = useState(isAfterDailyReportReminderTime());
|
||||||
const [mobileBellHint, setMobileBellHint] = useState("");
|
const [mobileBellHint, setMobileBellHint] = useState("");
|
||||||
const mobileBellTimerRef = useRef<number | null>(null);
|
const mobileBellTimerRef = useRef<number | null>(null);
|
||||||
|
const isMobileViewport = useIsMobileViewport();
|
||||||
|
const isWecomBrowser = useIsWecomBrowser();
|
||||||
|
const isWecomMobile = isMobileViewport && isWecomBrowser;
|
||||||
const isActivePath = (path: string) => location.pathname === path || (path !== "/" && location.pathname.startsWith(`${path}/`));
|
const isActivePath = (path: string) => location.pathname === path || (path !== "/" && location.pathname.startsWith(`${path}/`));
|
||||||
const activeNavItem = navItems.find((item) => isActivePath(item.path)) ?? navItems[0];
|
const activeNavItem = navItems.find((item) => isActivePath(item.path)) ?? navItems[0];
|
||||||
const contentKey = activeNavItem.path;
|
const contentKey = activeNavItem.path;
|
||||||
|
|
@ -152,9 +157,14 @@ export default function Layout() {
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="relative min-w-0 flex-1 overflow-x-hidden overflow-y-auto pb-[calc(6.5rem+env(safe-area-inset-bottom))] md:pb-0">
|
<main className="relative min-w-0 flex-1 overflow-x-hidden overflow-y-visible pb-[calc(6.5rem+env(safe-area-inset-bottom))] md:overflow-y-auto md:pb-0">
|
||||||
<div className="mx-auto w-full max-w-5xl px-4 py-4 md:px-8 md:py-8">
|
<div className="mx-auto w-full max-w-5xl px-4 py-4 md:px-8 md:py-8">
|
||||||
<header className="sticky top-0 z-30 -mx-4 mb-4 flex items-center justify-between gap-3 border-b border-slate-200 bg-white/90 px-4 pb-3.5 pt-[calc(0.9rem+env(safe-area-inset-top))] backdrop-blur-xl transition-colors duration-300 dark:border-slate-800 dark:bg-slate-950/90 md:hidden">
|
<header className={cn(
|
||||||
|
"sticky top-0 z-30 -mx-4 mb-4 flex items-center justify-between gap-3 border-b border-slate-200 px-4 pb-3.5 pt-[calc(0.9rem+env(safe-area-inset-top))] dark:border-slate-800 md:hidden",
|
||||||
|
isWecomMobile
|
||||||
|
? "bg-white dark:bg-slate-950"
|
||||||
|
: "bg-white/90 backdrop-blur-xl transition-colors duration-300 dark:bg-slate-950/90",
|
||||||
|
)}>
|
||||||
<div className="min-w-0 flex-1 pr-2">
|
<div className="min-w-0 flex-1 pr-2">
|
||||||
<p className="bg-gradient-to-r from-violet-600 to-indigo-600 bg-clip-text text-sm font-bold uppercase tracking-[0.14em] text-transparent">
|
<p className="bg-gradient-to-r from-violet-600 to-indigo-600 bg-clip-text text-sm font-bold uppercase tracking-[0.14em] text-transparent">
|
||||||
紫光汇智CRM
|
紫光汇智CRM
|
||||||
|
|
@ -166,7 +176,12 @@ export default function Layout() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleMobileBellClick}
|
onClick={handleMobileBellClick}
|
||||||
className="relative inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200/80 bg-white/80 text-slate-500 shadow-sm transition-colors hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-400 dark:hover:bg-slate-800"
|
className={cn(
|
||||||
|
"relative inline-flex h-9 w-9 items-center justify-center rounded-full border text-slate-500 shadow-sm transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800",
|
||||||
|
isWecomMobile
|
||||||
|
? "border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
: "border-slate-200/80 bg-white/80 dark:border-slate-700 dark:bg-slate-900/70",
|
||||||
|
)}
|
||||||
aria-label={hasDailyReportReminderDot ? "今日日报未提交,前往日报" : "日报提醒,前往日报"}
|
aria-label={hasDailyReportReminderDot ? "今日日报未提交,前往日报" : "日报提醒,前往日报"}
|
||||||
title={hasDailyReportReminderDot ? "今日日报未提交" : "日报提醒"}
|
title={hasDailyReportReminderDot ? "今日日报未提交" : "日报提醒"}
|
||||||
>
|
>
|
||||||
|
|
@ -184,7 +199,12 @@ export default function Layout() {
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200/80 bg-white/80 text-slate-500 shadow-sm transition-colors hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-400 dark:hover:bg-slate-800"
|
className={cn(
|
||||||
|
"inline-flex h-9 w-9 items-center justify-center rounded-full border text-slate-500 shadow-sm transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800",
|
||||||
|
isWecomMobile
|
||||||
|
? "border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
: "border-slate-200/80 bg-white/80 dark:border-slate-700 dark:bg-slate-900/70",
|
||||||
|
)}
|
||||||
aria-label={theme === "dark" ? "切换亮色模式" : "切换暗色模式"}
|
aria-label={theme === "dark" ? "切换亮色模式" : "切换暗色模式"}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
|
@ -206,6 +226,11 @@ export default function Layout() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isMobileViewport ? (
|
||||||
|
<div key={contentKey}>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={contentKey}
|
key={contentKey}
|
||||||
|
|
@ -217,11 +242,17 @@ export default function Layout() {
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Mobile Bottom Nav */}
|
{/* Mobile Bottom Nav */}
|
||||||
<nav className="fixed bottom-0 left-0 right-0 z-30 flex h-[calc(4rem+env(safe-area-inset-bottom))] border-t border-slate-200 bg-white/80 px-2 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl transition-colors duration-300 dark:border-slate-800 dark:bg-slate-900/80 md:hidden">
|
<nav className={cn(
|
||||||
|
"fixed bottom-0 left-0 right-0 z-30 flex h-[calc(4rem+env(safe-area-inset-bottom))] border-t border-slate-200 px-2 pb-[env(safe-area-inset-bottom)] dark:border-slate-800 md:hidden",
|
||||||
|
isWecomMobile
|
||||||
|
? "bg-white dark:bg-slate-900"
|
||||||
|
: "bg-white/80 backdrop-blur-xl transition-colors duration-300 dark:bg-slate-900/80",
|
||||||
|
)}>
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive = isActivePath(item.path);
|
const isActive = isActivePath(item.path);
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useIsMobileViewport() {
|
||||||
|
const [isMobile, setIsMobile] = useState(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return window.matchMedia("(max-width: 767px)").matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia("(max-width: 767px)");
|
||||||
|
const handleChange = () => setIsMobile(mediaQuery.matches);
|
||||||
|
handleChange();
|
||||||
|
|
||||||
|
if (typeof mediaQuery.addEventListener === "function") {
|
||||||
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
|
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaQuery.addListener(handleChange);
|
||||||
|
return () => mediaQuery.removeListener(handleChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export function useIsWecomBrowser() {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (typeof navigator === "undefined") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ua = navigator.userAgent.toLowerCase();
|
||||||
|
return ua.includes("wxwork");
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
@ -52,6 +52,13 @@ export interface WecomExchangePayload {
|
||||||
ticket: string;
|
ticket: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WecomJsSdkConfig {
|
||||||
|
corpId: string;
|
||||||
|
timestamp: number;
|
||||||
|
nonceStr: string;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardStat {
|
export interface DashboardStat {
|
||||||
name: string;
|
name: string;
|
||||||
value?: number;
|
value?: number;
|
||||||
|
|
@ -692,6 +699,11 @@ export async function exchangeWecomTicket(payload: WecomExchangePayload) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWecomJsSdkConfig(url: string) {
|
||||||
|
const params = new URLSearchParams({ url });
|
||||||
|
return request<WecomJsSdkConfig>(`/api/wecom/sso/js-sdk-config?${params.toString()}`, undefined, true);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSystemParamValue(key: string, defaultValue?: string) {
|
export async function getSystemParamValue(key: string, defaultValue?: string) {
|
||||||
const params = new URLSearchParams({ key });
|
const params = new URLSearchParams({ key });
|
||||||
if (defaultValue !== undefined) {
|
if (defaultValue !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export type TencentMapLocation = {
|
||||||
longitude: number;
|
longitude: number;
|
||||||
address: string;
|
address: string;
|
||||||
accuracy?: number;
|
accuracy?: number;
|
||||||
sourceType?: "browser" | "tencent";
|
sourceType?: "browser" | "tencent" | "wecom";
|
||||||
};
|
};
|
||||||
|
|
||||||
const EARTH_SEMI_MAJOR_AXIS = 6378245.0;
|
const EARTH_SEMI_MAJOR_AXIS = 6378245.0;
|
||||||
|
|
@ -90,6 +90,14 @@ export async function resolveTencentMapLocation() {
|
||||||
throw new Error(buildLocationFailureMessage(browserError, sdkError));
|
throw new Error(buildLocationFailureMessage(browserError, sdkError));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveBrowserLocation() {
|
||||||
|
return resolveWithBrowser();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveTencentSdkLocation() {
|
||||||
|
return resolveWithTencentSdk();
|
||||||
|
}
|
||||||
|
|
||||||
function mergeLocationAddress(
|
function mergeLocationAddress(
|
||||||
bestResult: TencentMapLocation,
|
bestResult: TencentMapLocation,
|
||||||
sdkResult: TencentMapLocation | null,
|
sdkResult: TencentMapLocation | null,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { getWecomJsSdkConfig } from "@/lib/auth";
|
||||||
|
import type { TencentMapLocation } from "@/lib/tencentMap";
|
||||||
|
|
||||||
|
const WECOM_JSSDK_SCRIPT_ID = "wecom-jssdk-script";
|
||||||
|
const WECOM_JXWORK_SCRIPT_ID = "wecom-jxwork-script";
|
||||||
|
const WECOM_JSSDK_SCRIPT_SRC = "https://res.wx.qq.com/open/js/jweixin-1.2.0.js";
|
||||||
|
const WECOM_JXWORK_SCRIPT_SRC = "https://open.work.weixin.qq.com/wwopen/js/jwxwork-1.0.0.js";
|
||||||
|
|
||||||
|
type WecomJsSdk = {
|
||||||
|
config: (options: {
|
||||||
|
beta?: boolean;
|
||||||
|
debug?: boolean;
|
||||||
|
appId: string;
|
||||||
|
timestamp: number;
|
||||||
|
nonceStr: string;
|
||||||
|
signature: string;
|
||||||
|
jsApiList: string[];
|
||||||
|
}) => void;
|
||||||
|
ready: (callback: () => void) => void;
|
||||||
|
error: (callback: (error: { errMsg?: string }) => void) => void;
|
||||||
|
getLocation: (options: {
|
||||||
|
type?: "wgs84" | "gcj02";
|
||||||
|
success?: (result: {
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
accuracy?: number;
|
||||||
|
}) => void;
|
||||||
|
fail?: (error?: { errMsg?: string }) => void;
|
||||||
|
cancel?: (error?: { errMsg?: string }) => void;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
wx?: WecomJsSdk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadSdkPromise: Promise<WecomJsSdk> | null = null;
|
||||||
|
let configuredUrl = "";
|
||||||
|
let configPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export function isWecomJsSdkLocationEnabled() {
|
||||||
|
return String(import.meta.env.VITE_WECOM_JSSDK_LOCATION_ENABLED || "").trim().toLowerCase() === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWecomBrowser() {
|
||||||
|
if (typeof navigator === "undefined") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /wxwork/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveWecomLocation(fetchConfig = getWecomJsSdkConfig): Promise<TencentMapLocation> {
|
||||||
|
if (!isWecomBrowser()) {
|
||||||
|
throw new Error("当前不是企业微信内置浏览器,无法调用企业微信定位。");
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = window.location.href.split("#")[0];
|
||||||
|
const wx = await loadWecomJsSdk();
|
||||||
|
await ensureWecomConfigured(wx, url, fetchConfig);
|
||||||
|
|
||||||
|
return new Promise<TencentMapLocation>((resolve, reject) => {
|
||||||
|
wx.getLocation({
|
||||||
|
type: "wgs84",
|
||||||
|
success: (result) => {
|
||||||
|
const latitude = typeof result.latitude === "number" ? Number(result.latitude.toFixed(6)) : NaN;
|
||||||
|
const longitude = typeof result.longitude === "number" ? Number(result.longitude.toFixed(6)) : NaN;
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||||
|
reject(new Error("企业微信返回的定位结果无效,请重试。"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
address: "",
|
||||||
|
accuracy: typeof result.accuracy === "number" ? result.accuracy : undefined,
|
||||||
|
sourceType: "wecom",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel: () => reject(new Error("企业微信定位已取消,请允许定位权限后重试。")),
|
||||||
|
fail: (error) => reject(new Error(normalizeWecomErrorMessage(error?.errMsg))),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWecomConfigured(
|
||||||
|
wx: WecomJsSdk,
|
||||||
|
url: string,
|
||||||
|
fetchConfig: typeof getWecomJsSdkConfig,
|
||||||
|
) {
|
||||||
|
if (configuredUrl === url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configPromise) {
|
||||||
|
await configPromise;
|
||||||
|
if (configuredUrl === url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configPromise = (async () => {
|
||||||
|
const config = await fetchConfig(url);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (callback: () => void) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
wx.ready(() => finish(() => resolve()));
|
||||||
|
wx.error((error) => finish(() => reject(new Error(normalizeWecomErrorMessage(error?.errMsg)))));
|
||||||
|
wx.config({
|
||||||
|
beta: true,
|
||||||
|
debug: false,
|
||||||
|
appId: config.corpId,
|
||||||
|
timestamp: config.timestamp,
|
||||||
|
nonceStr: config.nonceStr,
|
||||||
|
signature: config.signature,
|
||||||
|
jsApiList: ["getLocation"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
configuredUrl = url;
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await configPromise;
|
||||||
|
} finally {
|
||||||
|
configPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWecomJsSdk() {
|
||||||
|
if (window.wx) {
|
||||||
|
return window.wx;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loadSdkPromise) {
|
||||||
|
loadSdkPromise = (async () => {
|
||||||
|
await loadScript(WECOM_JSSDK_SCRIPT_ID, WECOM_JSSDK_SCRIPT_SRC);
|
||||||
|
await loadScript(WECOM_JXWORK_SCRIPT_ID, WECOM_JXWORK_SCRIPT_SRC);
|
||||||
|
if (!window.wx) {
|
||||||
|
throw new Error("企业微信 JS-SDK 加载失败,请检查企业微信内网访问后重试。");
|
||||||
|
}
|
||||||
|
return window.wx;
|
||||||
|
})().catch((error) => {
|
||||||
|
loadSdkPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadSdkPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadScript(id: string, src: string) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const existing = document.getElementById(id) as HTMLScriptElement | null;
|
||||||
|
if (existing) {
|
||||||
|
if ((existing as HTMLScriptElement).dataset.loaded === "true") {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
existing.addEventListener("load", () => resolve(), { once: true });
|
||||||
|
existing.addEventListener("error", () => reject(new Error("企业微信 JS-SDK 脚本加载失败。")), { once: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.id = id;
|
||||||
|
script.src = src;
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.onload = () => {
|
||||||
|
script.dataset.loaded = "true";
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
script.onerror = () => reject(new Error("企业微信 JS-SDK 脚本加载失败。"));
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWecomErrorMessage(errorMessage?: string) {
|
||||||
|
const message = (errorMessage || "").trim();
|
||||||
|
if (!message) {
|
||||||
|
return "企业微信定位失败,请稍后重试。";
|
||||||
|
}
|
||||||
|
if (message.includes("permission") || message.includes("denied")) {
|
||||||
|
return "企业微信定位权限被拒绝,请在手机系统和企业微信中允许定位权限后重试。";
|
||||||
|
}
|
||||||
|
if (message.includes("cancel")) {
|
||||||
|
return "企业微信定位已取消,请允许定位权限后重试。";
|
||||||
|
}
|
||||||
|
if (message.includes("config:fail") || message.includes("signature")) {
|
||||||
|
return "企业微信 JS-SDK 鉴权失败,请检查可信域名和签名配置。";
|
||||||
|
}
|
||||||
|
if (message.includes("function not exist")) {
|
||||||
|
return "当前企业微信版本过低,不支持定位接口,请升级后重试。";
|
||||||
|
}
|
||||||
|
return `企业微信定位失败:${message}`;
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@ import { BarChart3, Building2, Check, TrendingUp, Users } from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { completeDashboardTodo, getDashboardHome, type DashboardActivity, type DashboardHome, type DashboardStat, type DashboardTodo } from "@/lib/auth";
|
import { completeDashboardTodo, getDashboardHome, type DashboardActivity, type DashboardHome, type DashboardStat, type DashboardTodo } from "@/lib/auth";
|
||||||
|
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
|
||||||
|
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||||
|
|
||||||
const DASHBOARD_PREVIEW_COUNT = 5;
|
const DASHBOARD_PREVIEW_COUNT = 5;
|
||||||
const DASHBOARD_HISTORY_PREVIEW_COUNT = 3;
|
const DASHBOARD_HISTORY_PREVIEW_COUNT = 3;
|
||||||
|
|
@ -23,6 +25,9 @@ const statRoutes: Record<(typeof baseStats)[number]["metricKey"], { pathname: st
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isMobileViewport = useIsMobileViewport();
|
||||||
|
const isWecomBrowser = useIsWecomBrowser();
|
||||||
|
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
||||||
const [home, setHome] = useState<DashboardHome>({});
|
const [home, setHome] = useState<DashboardHome>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showAllActivities, setShowAllActivities] = useState(false);
|
const [showAllActivities, setShowAllActivities] = useState(false);
|
||||||
|
|
@ -128,18 +133,18 @@ export default function Dashboard() {
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="dashboard-skeleton"
|
key="dashboard-skeleton"
|
||||||
initial={{ opacity: 0 }}
|
initial={disableMobileMotion ? false : { opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
transition={{ duration: disableMobileMotion ? 0 : 0.22, ease: "easeOut" }}
|
||||||
>
|
>
|
||||||
<DashboardSkeleton />
|
<DashboardSkeleton />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="dashboard-content"
|
key="dashboard-content"
|
||||||
initial={{ opacity: 0 }}
|
initial={disableMobileMotion ? false : { opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ duration: 0.24, ease: "easeOut" }}
|
transition={{ duration: disableMobileMotion ? 0 : 0.24, ease: "easeOut" }}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-x-3 gap-y-4 sm:gap-4 xl:grid-cols-4">
|
<div className="grid grid-cols-2 gap-x-3 gap-y-4 sm:gap-4 xl:grid-cols-4">
|
||||||
{stats.map((stat, i) => (
|
{stats.map((stat, i) => (
|
||||||
|
|
@ -147,11 +152,11 @@ export default function Dashboard() {
|
||||||
key={stat.name}
|
key={stat.name}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleStatCardClick(stat.metricKey)}
|
onClick={() => handleStatCardClick(stat.metricKey)}
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: i * 0.1 }}
|
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.1 }}
|
||||||
className="crm-card min-h-[88px] rounded-xl p-3 text-left transition-all hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 dark:hover:bg-slate-900 sm:min-h-[104px] sm:rounded-2xl sm:p-5"
|
className="crm-card min-h-[88px] rounded-xl p-3 text-left transition-shadow transition-colors hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 dark:hover:bg-slate-900 sm:min-h-[104px] sm:rounded-2xl sm:p-5"
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={disableMobileMotion ? undefined : { scale: 0.98 }}
|
||||||
aria-label={`查看${stat.name}`}
|
aria-label={`查看${stat.name}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 sm:gap-4">
|
<div className="flex items-center gap-2.5 sm:gap-4">
|
||||||
|
|
@ -169,9 +174,9 @@ export default function Dashboard() {
|
||||||
|
|
||||||
<div className="mt-5 grid gap-5 md:mt-6 md:grid-cols-2 md:gap-6">
|
<div className="mt-5 grid gap-5 md:mt-6 md:grid-cols-2 md:gap-6">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.4 }}
|
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.4 }}
|
||||||
className="crm-card crm-card-pad-lg rounded-2xl"
|
className="crm-card crm-card-pad-lg rounded-2xl"
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex items-center justify-between sm:mb-4">
|
<div className="mb-3 flex items-center justify-between sm:mb-4">
|
||||||
|
|
@ -266,9 +271,9 @@ export default function Dashboard() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.5 }}
|
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.5 }}
|
||||||
className="crm-card crm-card-pad-lg rounded-2xl"
|
className="crm-card crm-card-pad-lg rounded-2xl"
|
||||||
>
|
>
|
||||||
<h2 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">最新动态</h2>
|
<h2 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">最新动态</h2>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import {
|
||||||
type SalesExpansionItem,
|
type SalesExpansionItem,
|
||||||
} from "@/lib/auth";
|
} from "@/lib/auth";
|
||||||
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
||||||
|
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
|
||||||
|
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
|
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
|
||||||
|
|
@ -257,19 +259,24 @@ function ModalShell({
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer: ReactNode;
|
footer: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const isMobileViewport = useIsMobileViewport();
|
||||||
|
const isWecomBrowser = useIsWecomBrowser();
|
||||||
|
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={disableMobileMotion ? false : { opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={disableMobileMotion ? undefined : { opacity: 0 }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="fixed inset-0 z-[70] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70"
|
className={cn("fixed inset-0 z-[70] bg-slate-900/35 dark:bg-slate-950/70", !disableMobileMotion && "backdrop-blur-sm")}
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: 20 }}
|
exit={disableMobileMotion ? undefined : { opacity: 0, y: 20 }}
|
||||||
|
transition={disableMobileMotion ? { duration: 0 } : undefined}
|
||||||
className="fixed inset-0 z-[80] px-0 pb-0 pt-[env(safe-area-inset-top)] sm:p-6"
|
className="fixed inset-0 z-[80] px-0 pb-0 pt-[env(safe-area-inset-top)] sm:p-6"
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex h-[calc(100dvh-env(safe-area-inset-top))] w-full items-end sm:h-full sm:max-w-3xl sm:items-center">
|
<div className="mx-auto flex h-[calc(100dvh-env(safe-area-inset-top))] w-full items-end sm:h-full sm:max-w-3xl sm:items-center">
|
||||||
|
|
@ -320,6 +327,9 @@ function RequiredMark() {
|
||||||
|
|
||||||
export default function Expansion() {
|
export default function Expansion() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const isMobileViewport = useIsMobileViewport();
|
||||||
|
const isWecomBrowser = useIsWecomBrowser();
|
||||||
|
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
||||||
const [activeTab, setActiveTab] = useState<ExpansionTab>("sales");
|
const [activeTab, setActiveTab] = useState<ExpansionTab>("sales");
|
||||||
const [selectedItem, setSelectedItem] = useState<ExpansionItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<ExpansionItem | null>(null);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
|
|
@ -1020,17 +1030,17 @@ export default function Expansion() {
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenCreate}
|
onClick={handleOpenCreate}
|
||||||
className="crm-btn-sm crm-btn-primary flex items-center gap-2 active:scale-95"
|
className={cn("crm-btn-sm crm-btn-primary flex items-center gap-2", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
|
||||||
>
|
>
|
||||||
<Plus className="crm-icon-md" />
|
<Plus className="crm-icon-md" />
|
||||||
<span className="hidden sm:inline">新增</span>
|
<span className="hidden sm:inline">新增</span>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="crm-filter-bar flex backdrop-blur-sm">
|
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabChange("sales")}
|
onClick={() => handleTabChange("sales")}
|
||||||
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-all duration-200 ${
|
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
|
||||||
activeTab === "sales" ? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400" : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
activeTab === "sales" ? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400" : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -1038,7 +1048,7 @@ export default function Expansion() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleTabChange("channel")}
|
onClick={() => handleTabChange("channel")}
|
||||||
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-all duration-200 ${
|
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
|
||||||
activeTab === "channel" ? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400" : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
activeTab === "channel" ? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400" : "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -1062,12 +1072,12 @@ export default function Expansion() {
|
||||||
salesData.length > 0 ? (
|
salesData.length > 0 ? (
|
||||||
salesData.map((item, i) => (
|
salesData.map((item, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: i * 0.05 }}
|
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setSelectedItem(item)}
|
onClick={() => setSelectedItem(item)}
|
||||||
className="crm-card crm-card-pad relative cursor-pointer rounded-2xl transition-all hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
className="crm-card crm-card-pad relative cursor-pointer rounded-2xl transition-shadow transition-colors hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
@ -1100,12 +1110,12 @@ export default function Expansion() {
|
||||||
) : channelData.length > 0 ? (
|
) : channelData.length > 0 ? (
|
||||||
channelData.map((item, i) => (
|
channelData.map((item, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: i * 0.05 }}
|
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setSelectedItem(item)}
|
onClick={() => setSelectedItem(item)}
|
||||||
className="crm-card crm-card-pad relative cursor-pointer rounded-2xl transition-all hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
className="crm-card crm-card-pad relative cursor-pointer rounded-2xl transition-shadow transition-colors hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ import {
|
||||||
|
|
||||||
import "./login.css";
|
import "./login.css";
|
||||||
|
|
||||||
|
const REMEMBERED_LOGIN_KEY = "rememberedLoginCredentials";
|
||||||
|
const LEGACY_REMEMBERED_USERNAME_KEY = "rememberedUsername";
|
||||||
|
const REMEMBER_PASSWORD_TTL_MS = 5 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
|
@ -31,6 +35,56 @@ const DEFAULT_FORM: FormState = {
|
||||||
remember: true,
|
remember: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RememberedLoginPayload = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
tenantCode: string;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readRememberedLogin(): RememberedLoginPayload | null {
|
||||||
|
const rawValue = localStorage.getItem(REMEMBERED_LOGIN_KEY);
|
||||||
|
if (!rawValue) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(rawValue) as Partial<RememberedLoginPayload>;
|
||||||
|
if (
|
||||||
|
typeof payload.username !== "string" ||
|
||||||
|
typeof payload.password !== "string" ||
|
||||||
|
typeof payload.tenantCode !== "string" ||
|
||||||
|
typeof payload.expiresAt !== "number"
|
||||||
|
) {
|
||||||
|
localStorage.removeItem(REMEMBERED_LOGIN_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (payload.expiresAt <= Date.now()) {
|
||||||
|
localStorage.removeItem(REMEMBERED_LOGIN_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return payload as RememberedLoginPayload;
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem(REMEMBERED_LOGIN_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistRememberedLogin(payload: Pick<FormState, "username" | "password" | "tenantCode">) {
|
||||||
|
const value: RememberedLoginPayload = {
|
||||||
|
username: payload.username,
|
||||||
|
password: payload.password,
|
||||||
|
tenantCode: payload.tenantCode,
|
||||||
|
expiresAt: Date.now() + REMEMBER_PASSWORD_TTL_MS,
|
||||||
|
};
|
||||||
|
localStorage.setItem(REMEMBERED_LOGIN_KEY, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRememberedLogin() {
|
||||||
|
localStorage.removeItem(REMEMBERED_LOGIN_KEY);
|
||||||
|
localStorage.removeItem(LEGACY_REMEMBERED_USERNAME_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -62,9 +116,21 @@ export default function LoginPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const rememberedUsername = localStorage.getItem("rememberedUsername");
|
const rememberedLogin = readRememberedLogin();
|
||||||
|
if (rememberedLogin) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
username: rememberedLogin.username,
|
||||||
|
password: rememberedLogin.password,
|
||||||
|
tenantCode: rememberedLogin.tenantCode,
|
||||||
|
remember: true,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rememberedUsername = localStorage.getItem(LEGACY_REMEMBERED_USERNAME_KEY);
|
||||||
if (rememberedUsername) {
|
if (rememberedUsername) {
|
||||||
setForm((current) => ({ ...current, username: rememberedUsername }));
|
setForm((current) => ({ ...current, username: rememberedUsername, remember: true }));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -146,9 +212,13 @@ export default function LoginPage() {
|
||||||
persistLogin(tokenData, form.username.trim());
|
persistLogin(tokenData, form.username.trim());
|
||||||
|
|
||||||
if (form.remember) {
|
if (form.remember) {
|
||||||
localStorage.setItem("rememberedUsername", form.username.trim());
|
persistRememberedLogin({
|
||||||
|
username: form.username.trim(),
|
||||||
|
password: form.password,
|
||||||
|
tenantCode: form.tenantCode.trim(),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem("rememberedUsername");
|
clearRememberedLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -279,7 +349,7 @@ export default function LoginPage() {
|
||||||
checked={form.remember}
|
checked={form.remember}
|
||||||
onChange={(event) => handleChange("remember", event.target.checked)}
|
onChange={(event) => handleChange("remember", event.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span>记住用户名</span>
|
<span>记住密码(5天)</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Search, Plus, ChevronRight, ChevronDown, Check, Building, Calendar, Dol
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOverview, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type SalesExpansionItem } from "@/lib/auth";
|
import { createOpportunity, getExpansionOverview, getOpportunityMeta, getOpportunityOverview, pushOpportunityToOms, updateOpportunity, type ChannelExpansionItem, type CreateOpportunityPayload, type OpportunityDictOption, type OpportunityFollowUp, type OpportunityItem, type SalesExpansionItem } from "@/lib/auth";
|
||||||
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
||||||
|
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold";
|
const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold";
|
||||||
|
|
@ -266,10 +267,26 @@ function ModalShell({
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
footer: ReactNode;
|
footer: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const isMobileViewport = useIsMobileViewport();
|
||||||
|
const isWecomBrowser = useIsWecomBrowser();
|
||||||
|
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} className="fixed inset-0 z-[70] bg-slate-900/35 backdrop-blur-sm dark:bg-slate-950/70" />
|
<motion.div
|
||||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} className="fixed inset-0 z-[80] px-0 pb-0 pt-[env(safe-area-inset-top)] sm:p-6">
|
initial={disableMobileMotion ? false : { opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={disableMobileMotion ? undefined : { opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className={cn("fixed inset-0 z-[70] bg-slate-900/35 dark:bg-slate-950/70", !disableMobileMotion && "backdrop-blur-sm")}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={disableMobileMotion ? undefined : { opacity: 0, y: 20 }}
|
||||||
|
transition={disableMobileMotion ? { duration: 0 } : undefined}
|
||||||
|
className="fixed inset-0 z-[80] px-0 pb-0 pt-[env(safe-area-inset-top)] sm:p-6"
|
||||||
|
>
|
||||||
<div className="mx-auto flex h-[calc(100dvh-env(safe-area-inset-top))] w-full items-end sm:h-full sm:max-w-3xl sm:items-center">
|
<div className="mx-auto flex h-[calc(100dvh-env(safe-area-inset-top))] w-full items-end sm:h-full sm:max-w-3xl sm:items-center">
|
||||||
<div className="flex h-[92dvh] w-full flex-col overflow-hidden rounded-t-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900 sm:h-full sm:rounded-3xl">
|
<div className="flex h-[92dvh] w-full flex-col overflow-hidden rounded-t-3xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900 sm:h-full sm:rounded-3xl">
|
||||||
<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 justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800 sm:px-6">
|
||||||
|
|
@ -551,6 +568,9 @@ function SearchableSelect({
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Opportunities() {
|
export default function Opportunities() {
|
||||||
|
const isMobileViewport = useIsMobileViewport();
|
||||||
|
const isWecomBrowser = useIsWecomBrowser();
|
||||||
|
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
||||||
const [archiveTab, setArchiveTab] = useState<OpportunityArchiveTab>("active");
|
const [archiveTab, setArchiveTab] = useState<OpportunityArchiveTab>("active");
|
||||||
const [filter, setFilter] = useState("全部");
|
const [filter, setFilter] = useState("全部");
|
||||||
const [stageFilterOpen, setStageFilterOpen] = useState(false);
|
const [stageFilterOpen, setStageFilterOpen] = useState(false);
|
||||||
|
|
@ -850,16 +870,19 @@ export default function Opportunities() {
|
||||||
<h1 className="crm-page-title">商机储备</h1>
|
<h1 className="crm-page-title">商机储备</h1>
|
||||||
<p className="crm-page-subtitle hidden sm:block">维护商机基础信息,查看关联拓展对象和日报自动回写的跟进记录。</p>
|
<p className="crm-page-subtitle hidden sm:block">维护商机基础信息,查看关联拓展对象和日报自动回写的跟进记录。</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleOpenCreate} className="crm-btn-sm crm-btn-primary flex items-center gap-2 active:scale-95">
|
<button
|
||||||
|
onClick={handleOpenCreate}
|
||||||
|
className={cn("crm-btn-sm crm-btn-primary flex items-center gap-2", disableMobileMotion ? "active:scale-100" : "active:scale-95")}
|
||||||
|
>
|
||||||
<Plus className="crm-icon-md" />
|
<Plus className="crm-icon-md" />
|
||||||
<span className="hidden sm:inline">新增商机</span>
|
<span className="hidden sm:inline">新增商机</span>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="crm-filter-bar flex backdrop-blur-sm">
|
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setArchiveTab("active")}
|
onClick={() => setArchiveTab("active")}
|
||||||
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-all duration-200 ${
|
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
|
||||||
archiveTab === "active"
|
archiveTab === "active"
|
||||||
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
||||||
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||||
|
|
@ -869,7 +892,7 @@ export default function Opportunities() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setArchiveTab("archived")}
|
onClick={() => setArchiveTab("archived")}
|
||||||
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-all duration-200 ${
|
className={`flex-1 rounded-lg py-2 text-sm font-medium transition-colors duration-200 ${
|
||||||
archiveTab === "archived"
|
archiveTab === "archived"
|
||||||
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
||||||
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||||
|
|
@ -910,12 +933,12 @@ export default function Opportunities() {
|
||||||
{visibleItems.length > 0 ? (
|
{visibleItems.length > 0 ? (
|
||||||
visibleItems.map((opp, i) => (
|
visibleItems.map((opp, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: i * 0.05 }}
|
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
||||||
key={opp.id}
|
key={opp.id}
|
||||||
onClick={() => setSelectedItem(opp)}
|
onClick={() => setSelectedItem(opp)}
|
||||||
className="crm-card crm-card-pad group relative cursor-pointer rounded-2xl transition-all hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
className="crm-card crm-card-pad group relative cursor-pointer rounded-2xl transition-shadow transition-colors hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTheme } from "@/components/ThemeProvider";
|
import { useTheme } from "@/components/ThemeProvider";
|
||||||
|
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
|
||||||
|
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||||
import {
|
import {
|
||||||
clearAuth,
|
clearAuth,
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
|
|
@ -126,6 +128,9 @@ function PageModal({
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isMobileViewport = useIsMobileViewport();
|
||||||
|
const isWecomBrowser = useIsWecomBrowser();
|
||||||
|
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
||||||
|
|
||||||
const [overview, setOverview] = useState<ProfileOverview | null>(null);
|
const [overview, setOverview] = useState<ProfileOverview | null>(null);
|
||||||
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
|
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
|
||||||
|
|
@ -345,9 +350,10 @@ export default function Profile() {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="crm-card crm-card-pad-lg relative rounded-2xl transition-all"
|
transition={disableMobileMotion ? { duration: 0 } : undefined}
|
||||||
|
className="crm-card crm-card-pad-lg relative rounded-2xl transition-shadow transition-colors"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleOpenProfile()}
|
onClick={() => void handleOpenProfile()}
|
||||||
|
|
@ -375,7 +381,7 @@ export default function Profile() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNavigateToMonthlyOpportunity}
|
onClick={handleNavigateToMonthlyOpportunity}
|
||||||
className="rounded-2xl bg-slate-50 px-2 py-4 text-center transition-colors hover:bg-slate-100 active:scale-[0.99] dark:bg-slate-800/40 dark:hover:bg-slate-800 sm:rounded-none sm:bg-transparent sm:px-2"
|
className="rounded-2xl bg-slate-50 px-2 py-4 text-center transition-colors hover:bg-slate-100 active:scale-100 dark:bg-slate-800/40 dark:hover:bg-slate-800 sm:rounded-none sm:bg-transparent sm:px-2 sm:active:scale-[0.99]"
|
||||||
>
|
>
|
||||||
<p className="text-lg font-bold text-slate-900 dark:text-white sm:text-2xl">{numericValue(overview?.monthlyOpportunityCount)}</p>
|
<p className="text-lg font-bold text-slate-900 dark:text-white sm:text-2xl">{numericValue(overview?.monthlyOpportunityCount)}</p>
|
||||||
<p className="mt-1 text-[11px] text-slate-500 dark:text-slate-400">本月商机</p>
|
<p className="mt-1 text-[11px] text-slate-500 dark:text-slate-400">本月商机</p>
|
||||||
|
|
@ -383,7 +389,7 @@ export default function Profile() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNavigateToMonthlyExpansion}
|
onClick={handleNavigateToMonthlyExpansion}
|
||||||
className="rounded-2xl bg-slate-50 px-2 py-4 text-center transition-colors hover:bg-slate-100 active:scale-[0.99] dark:bg-slate-800/40 dark:hover:bg-slate-800 sm:rounded-none sm:bg-transparent sm:px-2"
|
className="rounded-2xl bg-slate-50 px-2 py-4 text-center transition-colors hover:bg-slate-100 active:scale-100 dark:bg-slate-800/40 dark:hover:bg-slate-800 sm:rounded-none sm:bg-transparent sm:px-2 sm:active:scale-[0.99]"
|
||||||
>
|
>
|
||||||
<p className="text-lg font-bold text-slate-900 dark:text-white sm:text-2xl">{numericValue(overview?.monthlyExpansionCount)}</p>
|
<p className="text-lg font-bold text-slate-900 dark:text-white sm:text-2xl">{numericValue(overview?.monthlyExpansionCount)}</p>
|
||||||
<p className="mt-1 text-[11px] text-slate-500 dark:text-slate-400">本月拓展</p>
|
<p className="mt-1 text-[11px] text-slate-500 dark:text-slate-400">本月拓展</p>
|
||||||
|
|
@ -396,10 +402,10 @@ export default function Profile() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.1 }}
|
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.1 }}
|
||||||
className="crm-card overflow-hidden rounded-2xl transition-all"
|
className="crm-card overflow-hidden rounded-2xl transition-shadow transition-colors"
|
||||||
>
|
>
|
||||||
<ul className="divide-y divide-slate-50 dark:divide-slate-800/50">
|
<ul className="divide-y divide-slate-50 dark:divide-slate-800/50">
|
||||||
<li>
|
<li>
|
||||||
|
|
@ -446,11 +452,11 @@ export default function Profile() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.button
|
<motion.button
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.2 }}
|
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.2 }}
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="crm-btn crm-btn-danger flex w-full items-center justify-center gap-2 active:scale-[0.98]"
|
className="crm-btn crm-btn-danger flex w-full items-center justify-center gap-2 active:scale-100 sm:active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
<LogOut className="crm-icon-lg" />
|
<LogOut className="crm-icon-lg" />
|
||||||
退出登录
|
退出登录
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,12 @@ import {
|
||||||
type WorkTomorrowPlanItem,
|
type WorkTomorrowPlanItem,
|
||||||
} from "@/lib/auth";
|
} from "@/lib/auth";
|
||||||
import { ProtectedImage } from "@/components/ProtectedImage";
|
import { ProtectedImage } from "@/components/ProtectedImage";
|
||||||
import { gcj02ToWgs84, resolveTencentMapLocation, wgs84ToGcj02 } from "@/lib/tencentMap";
|
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
|
||||||
|
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||||
|
import { gcj02ToWgs84, resolveBrowserLocation, resolveTencentSdkLocation, wgs84ToGcj02 } from "@/lib/tencentMap";
|
||||||
import { loadTencentMapGlApi } from "@/lib/tencentMapGl";
|
import { loadTencentMapGlApi } from "@/lib/tencentMapGl";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { isWecomBrowser, isWecomJsSdkLocationEnabled, resolveWecomLocation } from "@/lib/wecom";
|
||||||
|
|
||||||
const reportFieldLabels = {
|
const reportFieldLabels = {
|
||||||
sales: ["沟通内容", "后续规划"],
|
sales: ["沟通内容", "后续规划"],
|
||||||
|
|
@ -129,6 +132,9 @@ function getReportStatus(status?: string) {
|
||||||
|
|
||||||
export default function Work() {
|
export default function Work() {
|
||||||
const routerLocation = useLocation();
|
const routerLocation = useLocation();
|
||||||
|
const isMobileViewport = useIsMobileViewport();
|
||||||
|
const isWecomInternalBrowser = useIsWecomBrowser();
|
||||||
|
const disableMobileMotion = isMobileViewport || isWecomInternalBrowser;
|
||||||
const currentWorkDate = format(new Date(), "yyyy-MM-dd");
|
const currentWorkDate = format(new Date(), "yyyy-MM-dd");
|
||||||
const hasAutoRefreshedLocation = useRef(false);
|
const hasAutoRefreshedLocation = useRef(false);
|
||||||
const photoInputRef = useRef<HTMLInputElement | null>(null);
|
const photoInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
@ -403,7 +409,36 @@ export default function Work() {
|
||||||
|
|
||||||
setLocationHint("正在获取当前位置...");
|
setLocationHint("正在获取当前位置...");
|
||||||
try {
|
try {
|
||||||
const position = await resolveTencentMapLocation();
|
let position = null;
|
||||||
|
let wecomError: Error | null = null;
|
||||||
|
let browserError: Error | null = null;
|
||||||
|
let tencentError: Error | null = null;
|
||||||
|
|
||||||
|
if (isWecomBrowser() && isWecomJsSdkLocationEnabled()) {
|
||||||
|
position = await resolveWecomLocation().catch((error) => {
|
||||||
|
wecomError = error instanceof Error ? error : new Error("企业微信定位失败");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!position) {
|
||||||
|
position = await resolveBrowserLocation().catch((error) => {
|
||||||
|
browserError = error instanceof Error ? error : new Error("浏览器定位失败");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!position) {
|
||||||
|
position = await resolveTencentSdkLocation().catch((error) => {
|
||||||
|
tencentError = error instanceof Error ? error : new Error("腾讯定位失败");
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!position) {
|
||||||
|
const reasons = [wecomError && `企业微信定位:${wecomError.message}`, browserError && `浏览器定位:${browserError.message}`, tencentError && `腾讯定位:${tencentError.message}`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(";");
|
||||||
|
throw new Error(reasons || "定位获取失败,请稍后重试。");
|
||||||
|
}
|
||||||
|
|
||||||
const latitude = position.latitude;
|
const latitude = position.latitude;
|
||||||
const longitude = position.longitude;
|
const longitude = position.longitude;
|
||||||
const displayName = await resolveLocationDisplayName(latitude, longitude, position.address);
|
const displayName = await resolveLocationDisplayName(latitude, longitude, position.address);
|
||||||
|
|
@ -417,7 +452,11 @@ export default function Work() {
|
||||||
setLocationAccuracyMeters(position.accuracy);
|
setLocationAccuracyMeters(position.accuracy);
|
||||||
setLocationAdjustmentConfirmed(false);
|
setLocationAdjustmentConfirmed(false);
|
||||||
const accuracyText = position.accuracy ? `当前精度约 ${Math.round(position.accuracy)} 米` : "定位已刷新";
|
const accuracyText = position.accuracy ? `当前精度约 ${Math.round(position.accuracy)} 米` : "定位已刷新";
|
||||||
const sourceText = position.sourceType === "tencent" ? "腾讯定位" : "浏览器定位";
|
const sourceText = position.sourceType === "wecom"
|
||||||
|
? "企业微信定位"
|
||||||
|
: position.sourceType === "tencent"
|
||||||
|
? "腾讯定位"
|
||||||
|
: "浏览器定位";
|
||||||
setLocationHint(
|
setLocationHint(
|
||||||
position.accuracy && position.accuracy > CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS
|
position.accuracy && position.accuracy > CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS
|
||||||
? `${sourceText} ${accuracyText},当前精度超过 ${CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS} 米,不能直接打卡,请先重新定位或调整位置确认。`
|
? `${sourceText} ${accuracyText},当前精度超过 ${CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS} 米,不能直接打卡,请先重新定位或调整位置确认。`
|
||||||
|
|
@ -743,7 +782,7 @@ export default function Work() {
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<WorkSectionNav activeWorkSection={activeWorkSection} />
|
<WorkSectionNav activeWorkSection={activeWorkSection} disableMobileMotion={disableMobileMotion} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-12 lg:gap-6">
|
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-12 lg:gap-6">
|
||||||
<div className={`min-w-0 crm-page-stack lg:col-span-7 xl:col-span-8 ${mobilePanel === "entry" ? "block lg:flex" : "hidden lg:flex"}`}>
|
<div className={`min-w-0 crm-page-stack lg:col-span-7 xl:col-span-8 ${mobilePanel === "entry" ? "block lg:flex" : "hidden lg:flex"}`}>
|
||||||
|
|
@ -782,6 +821,7 @@ export default function Work() {
|
||||||
pageError={pageError}
|
pageError={pageError}
|
||||||
submittingCheckIn={submittingCheckIn}
|
submittingCheckIn={submittingCheckIn}
|
||||||
onSubmit={() => void handleCheckInSubmit()}
|
onSubmit={() => void handleCheckInSubmit()}
|
||||||
|
disableMobileMotion={disableMobileMotion}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ReportPanel
|
<ReportPanel
|
||||||
|
|
@ -802,6 +842,7 @@ export default function Work() {
|
||||||
pageError={pageError}
|
pageError={pageError}
|
||||||
submittingReport={submittingReport}
|
submittingReport={submittingReport}
|
||||||
onSubmit={() => void handleReportSubmit()}
|
onSubmit={() => void handleReportSubmit()}
|
||||||
|
disableMobileMotion={disableMobileMotion}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -825,6 +866,7 @@ export default function Work() {
|
||||||
index={index}
|
index={index}
|
||||||
onOpen={() => setHistoryDetailItem(item)}
|
onOpen={() => setHistoryDetailItem(item)}
|
||||||
onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })}
|
onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })}
|
||||||
|
disableMobileMotion={disableMobileMotion}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -849,12 +891,15 @@ export default function Work() {
|
||||||
initial={false}
|
initial={false}
|
||||||
animate={{
|
animate={{
|
||||||
opacity: showHistoryBackToTop ? 1 : 0,
|
opacity: showHistoryBackToTop ? 1 : 0,
|
||||||
scale: showHistoryBackToTop ? 1 : 0.92,
|
scale: disableMobileMotion ? 1 : showHistoryBackToTop ? 1 : 0.92,
|
||||||
y: showHistoryBackToTop ? 0 : 8,
|
y: disableMobileMotion ? 0 : showHistoryBackToTop ? 0 : 8,
|
||||||
}}
|
}}
|
||||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
transition={disableMobileMotion ? { duration: 0 } : { duration: 0.18, ease: "easeOut" }}
|
||||||
onClick={() => historyScrollContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" })}
|
onClick={() => historyScrollContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" })}
|
||||||
className="sticky bottom-4 ml-auto flex h-10 w-10 items-center justify-center rounded-full border border-violet-200 bg-white/92 text-violet-600 shadow-lg backdrop-blur transition-colors hover:bg-violet-50 dark:border-violet-500/30 dark:bg-slate-900/92 dark:text-violet-300 dark:hover:bg-slate-800"
|
className={cn(
|
||||||
|
"sticky bottom-4 ml-auto flex h-10 w-10 items-center justify-center rounded-full border border-violet-200 text-violet-600 shadow-lg transition-colors hover:bg-violet-50 dark:border-violet-500/30 dark:text-violet-300 dark:hover:bg-slate-800",
|
||||||
|
disableMobileMotion ? "bg-white dark:bg-slate-900" : "bg-white/92 backdrop-blur dark:bg-slate-900/92",
|
||||||
|
)}
|
||||||
style={{ pointerEvents: showHistoryBackToTop ? "auto" : "none" }}
|
style={{ pointerEvents: showHistoryBackToTop ? "auto" : "none" }}
|
||||||
aria-label="回到历史记录顶部"
|
aria-label="回到历史记录顶部"
|
||||||
>
|
>
|
||||||
|
|
@ -869,7 +914,7 @@ export default function Work() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setObjectPicker(null)}
|
onClick={() => setObjectPicker(null)}
|
||||||
className="absolute inset-0 bg-slate-900/35 backdrop-blur-sm"
|
className={cn("absolute inset-0 bg-slate-900/35", !disableMobileMotion && "backdrop-blur-sm")}
|
||||||
aria-label="关闭选择对象"
|
aria-label="关闭选择对象"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-x-0 bottom-0 max-h-[82dvh] 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(720px,88vw)] md:max-h-[72vh] md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-3xl">
|
<div className="absolute inset-x-0 bottom-0 max-h-[82dvh] 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(720px,88vw)] md:max-h-[72vh] md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-3xl">
|
||||||
|
|
@ -952,6 +997,7 @@ export default function Work() {
|
||||||
currentPoint={{ latitude: checkInForm.latitude, longitude: checkInForm.longitude }}
|
currentPoint={{ latitude: checkInForm.latitude, longitude: checkInForm.longitude }}
|
||||||
radius={LOCATION_ADJUST_RADIUS_METERS}
|
radius={LOCATION_ADJUST_RADIUS_METERS}
|
||||||
currentAddress={checkInForm.locationText}
|
currentAddress={checkInForm.locationText}
|
||||||
|
disableMobileMotion={disableMobileMotion}
|
||||||
onClose={() => setLocationAdjustOpen(false)}
|
onClose={() => setLocationAdjustOpen(false)}
|
||||||
onConfirm={(point, address) => void handleApplyLocationAdjust(point, address)}
|
onConfirm={(point, address) => void handleApplyLocationAdjust(point, address)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -962,6 +1008,7 @@ export default function Work() {
|
||||||
item={historyDetailItem}
|
item={historyDetailItem}
|
||||||
onClose={() => setHistoryDetailItem(null)}
|
onClose={() => setHistoryDetailItem(null)}
|
||||||
onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })}
|
onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })}
|
||||||
|
disableMobileMotion={disableMobileMotion}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -970,6 +1017,7 @@ export default function Work() {
|
||||||
url={previewPhoto.url}
|
url={previewPhoto.url}
|
||||||
alt={previewPhoto.alt}
|
alt={previewPhoto.alt}
|
||||||
onClose={() => setPreviewPhoto(null)}
|
onClose={() => setPreviewPhoto(null)}
|
||||||
|
disableMobileMotion={disableMobileMotion}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -983,6 +1031,7 @@ function LocationAdjustModal({
|
||||||
currentAddress,
|
currentAddress,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
|
disableMobileMotion,
|
||||||
}: {
|
}: {
|
||||||
origin: LocationPoint;
|
origin: LocationPoint;
|
||||||
currentPoint: LocationPoint;
|
currentPoint: LocationPoint;
|
||||||
|
|
@ -990,6 +1039,7 @@ function LocationAdjustModal({
|
||||||
currentAddress: string;
|
currentAddress: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirm: (point: LocationPoint, address: string) => void;
|
onConfirm: (point: LocationPoint, address: string) => void;
|
||||||
|
disableMobileMotion: boolean;
|
||||||
}) {
|
}) {
|
||||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const mapRef = useRef<{
|
const mapRef = useRef<{
|
||||||
|
|
@ -1166,7 +1216,7 @@ function LocationAdjustModal({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
className={cn("absolute inset-0 bg-slate-900/40", !disableMobileMotion && "backdrop-blur-sm")}
|
||||||
aria-label="关闭调整位置"
|
aria-label="关闭调整位置"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-x-0 bottom-0 flex max-h-[92dvh] flex-col 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(860px,92vw)] md:max-h-[84vh] md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-3xl">
|
<div className="absolute inset-x-0 bottom-0 flex max-h-[92dvh] flex-col 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(860px,92vw)] md:max-h-[84vh] md:-translate-x-1/2 md:-translate-y-1/2 md:rounded-3xl">
|
||||||
|
|
@ -1252,11 +1302,13 @@ function LocationAdjustModal({
|
||||||
|
|
||||||
function WorkSectionNav({
|
function WorkSectionNav({
|
||||||
activeWorkSection,
|
activeWorkSection,
|
||||||
|
disableMobileMotion,
|
||||||
}: {
|
}: {
|
||||||
activeWorkSection: WorkSection;
|
activeWorkSection: WorkSection;
|
||||||
|
disableMobileMotion: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="crm-filter-bar flex backdrop-blur-sm">
|
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
|
||||||
{workSectionItems.map((item) => {
|
{workSectionItems.map((item) => {
|
||||||
const isActive = item.key === activeWorkSection;
|
const isActive = item.key === activeWorkSection;
|
||||||
return (
|
return (
|
||||||
|
|
@ -1264,7 +1316,7 @@ function WorkSectionNav({
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 rounded-lg px-4 py-2 text-center text-sm font-medium transition-all duration-200",
|
"flex-1 rounded-lg px-4 py-2 text-center text-sm font-medium transition-colors duration-200",
|
||||||
isActive
|
isActive
|
||||||
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
||||||
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||||
|
|
@ -1291,7 +1343,7 @@ function MobilePanelToggle({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange("entry")}
|
onClick={() => onChange("entry")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg px-4 py-1.5 text-sm font-medium transition-all duration-200",
|
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors duration-200",
|
||||||
mobilePanel === "entry"
|
mobilePanel === "entry"
|
||||||
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
||||||
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||||
|
|
@ -1303,7 +1355,7 @@ function MobilePanelToggle({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange("history")}
|
onClick={() => onChange("history")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg px-4 py-1.5 text-sm font-medium transition-all duration-200",
|
"rounded-lg px-4 py-1.5 text-sm font-medium transition-colors duration-200",
|
||||||
mobilePanel === "history"
|
mobilePanel === "history"
|
||||||
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
? "bg-white text-violet-600 shadow-sm dark:bg-slate-800 dark:text-violet-400"
|
||||||
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||||
|
|
@ -1387,17 +1439,19 @@ function HistoryCard({
|
||||||
index,
|
index,
|
||||||
onOpen,
|
onOpen,
|
||||||
onPreviewPhoto,
|
onPreviewPhoto,
|
||||||
|
disableMobileMotion,
|
||||||
}: {
|
}: {
|
||||||
item: WorkHistoryItem;
|
item: WorkHistoryItem;
|
||||||
index: number;
|
index: number;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onPreviewPhoto: (url: string, alt: string) => void;
|
onPreviewPhoto: (url: string, alt: string) => void;
|
||||||
|
disableMobileMotion: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: 20 }}
|
initial={disableMobileMotion ? false : { opacity: 0, x: 20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ delay: index * 0.06 }}
|
transition={disableMobileMotion ? { duration: 0 } : { delay: index * 0.06 }}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
|
|
@ -1407,7 +1461,7 @@ function HistoryCard({
|
||||||
onOpen();
|
onOpen();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="crm-card crm-card-pad cursor-pointer rounded-2xl transition-all hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
className="crm-card crm-card-pad cursor-pointer rounded-2xl transition-shadow transition-colors hover:border-violet-100 hover:shadow-md dark:hover:border-violet-900/50"
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex items-start justify-between">
|
<div className="mb-3 flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -1492,6 +1546,7 @@ function CheckInPanel({
|
||||||
pageError,
|
pageError,
|
||||||
submittingCheckIn,
|
submittingCheckIn,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
disableMobileMotion,
|
||||||
}: {
|
}: {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
checkInForm: CreateWorkCheckInPayload;
|
checkInForm: CreateWorkCheckInPayload;
|
||||||
|
|
@ -1515,14 +1570,16 @@ function CheckInPanel({
|
||||||
pageError: string;
|
pageError: string;
|
||||||
submittingCheckIn: boolean;
|
submittingCheckIn: boolean;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
|
disableMobileMotion: boolean;
|
||||||
}) {
|
}) {
|
||||||
const mobileCameraOnly = supportsMobileCameraCapture();
|
const mobileCameraOnly = supportsMobileCameraCapture();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="checkin"
|
key="checkin"
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={disableMobileMotion ? { duration: 0 } : undefined}
|
||||||
className="crm-card crm-card-pad rounded-2xl"
|
className="crm-card crm-card-pad rounded-2xl"
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between border-b border-slate-100 pb-4 dark:border-slate-800/60">
|
<div className="mb-4 flex items-center justify-between border-b border-slate-100 pb-4 dark:border-slate-800/60">
|
||||||
|
|
@ -1660,7 +1717,7 @@ function CheckInPanel({
|
||||||
value={checkInForm.remark ?? ""}
|
value={checkInForm.remark ?? ""}
|
||||||
onChange={(event) => onRemarkChange(event.target.value)}
|
onChange={(event) => onRemarkChange(event.target.value)}
|
||||||
placeholder="补充说明现场情况..."
|
placeholder="补充说明现场情况..."
|
||||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white text-slate-900 outline-none transition-all focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white text-slate-900 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1672,7 +1729,10 @@ function CheckInPanel({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabled={submittingCheckIn || loading || requiresLocationConfirmation}
|
disabled={submittingCheckIn || loading || requiresLocationConfirmation}
|
||||||
className="crm-btn crm-btn-success mt-6 flex w-full items-center justify-center gap-2 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
|
className={cn(
|
||||||
|
"crm-btn crm-btn-success mt-6 flex w-full items-center justify-center gap-2 disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
disableMobileMotion ? "active:scale-100" : "active:scale-[0.98]",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<CheckCircle2 className="crm-icon-md" />
|
<CheckCircle2 className="crm-icon-md" />
|
||||||
{submittingCheckIn ? "提交中..." : requiresLocationConfirmation ? "请先确认位置" : "确认打卡"}
|
{submittingCheckIn ? "提交中..." : requiresLocationConfirmation ? "请先确认位置" : "确认打卡"}
|
||||||
|
|
@ -1699,6 +1759,7 @@ function ReportPanel({
|
||||||
pageError,
|
pageError,
|
||||||
submittingReport,
|
submittingReport,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
disableMobileMotion,
|
||||||
}: {
|
}: {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
reportStatus?: string;
|
reportStatus?: string;
|
||||||
|
|
@ -1717,6 +1778,7 @@ function ReportPanel({
|
||||||
pageError: string;
|
pageError: string;
|
||||||
submittingReport: boolean;
|
submittingReport: boolean;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
|
disableMobileMotion: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [editingReportLineIndex, setEditingReportLineIndex] = useState<number | null>(null);
|
const [editingReportLineIndex, setEditingReportLineIndex] = useState<number | null>(null);
|
||||||
const [editingPlanItemIndex, setEditingPlanItemIndex] = useState<number | null>(null);
|
const [editingPlanItemIndex, setEditingPlanItemIndex] = useState<number | null>(null);
|
||||||
|
|
@ -1824,8 +1886,9 @@ function ReportPanel({
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="report"
|
key="report"
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={disableMobileMotion ? { duration: 0 } : undefined}
|
||||||
className="crm-card crm-card-pad rounded-2xl"
|
className="crm-card crm-card-pad rounded-2xl"
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between border-b border-slate-100 pb-4 dark:border-slate-800/60">
|
<div className="mb-4 flex items-center justify-between border-b border-slate-100 pb-4 dark:border-slate-800/60">
|
||||||
|
|
@ -1885,13 +1948,13 @@ function ReportPanel({
|
||||||
}}
|
}}
|
||||||
onBlur={() => setEditingReportLineIndex((current) => (current === index ? null : current))}
|
onBlur={() => setEditingReportLineIndex((current) => (current === index ? null : current))}
|
||||||
placeholder="先输入 @ 选择对象,系统会自动生成固定字段。"
|
placeholder="先输入 @ 选择对象,系统会自动生成固定字段。"
|
||||||
className="crm-input-box crm-input-text min-h-[96px] flex-1 resize-none overflow-hidden rounded-2xl border border-slate-200 bg-slate-50 leading-7 text-slate-900 outline-none transition-all focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
className="crm-input-box crm-input-text min-h-[96px] flex-1 resize-none overflow-hidden rounded-2xl border border-slate-200 bg-slate-50 leading-7 text-slate-900 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => activateReportLineEditor(index)}
|
onClick={() => activateReportLineEditor(index)}
|
||||||
className="min-h-[72px] flex-1 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-left transition-all hover:border-violet-300 dark:border-slate-800 dark:bg-slate-900/60 dark:hover:border-violet-500"
|
className="min-h-[72px] flex-1 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-left transition-colors hover:border-violet-300 dark:border-slate-800 dark:bg-slate-900/60 dark:hover:border-violet-500"
|
||||||
>
|
>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{collapsedPreviewLines.map((line, lineIndex) => (
|
{collapsedPreviewLines.map((line, lineIndex) => (
|
||||||
|
|
@ -1955,13 +2018,13 @@ function ReportPanel({
|
||||||
onChange={(event) => onPlanItemChange(index, event.target.value)}
|
onChange={(event) => onPlanItemChange(index, event.target.value)}
|
||||||
onBlur={() => setEditingPlanItemIndex((current) => (current === index ? null : current))}
|
onBlur={() => setEditingPlanItemIndex((current) => (current === index ? null : current))}
|
||||||
placeholder="输入明日工作计划"
|
placeholder="输入明日工作计划"
|
||||||
className="crm-input-box crm-input-text h-10 flex-1 rounded-xl border border-slate-200 bg-slate-50 text-slate-900 outline-none transition-all focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
className="crm-input-box crm-input-text h-10 flex-1 rounded-xl border border-slate-200 bg-slate-50 text-slate-900 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/60 dark:text-white"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => activatePlanItemEditor(index)}
|
onClick={() => activatePlanItemEditor(index)}
|
||||||
className="crm-btn-sm crm-input-text flex h-10 flex-1 items-center rounded-xl border border-slate-200 bg-slate-50 text-left transition-all hover:border-violet-300 dark:border-slate-800 dark:bg-slate-900/60 dark:hover:border-violet-500"
|
className="crm-btn-sm crm-input-text flex h-10 flex-1 items-center rounded-xl border border-slate-200 bg-slate-50 text-left transition-colors hover:border-violet-300 dark:border-slate-800 dark:bg-slate-900/60 dark:hover:border-violet-500"
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -1997,7 +2060,10 @@ function ReportPanel({
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabled={submittingReport || loading}
|
disabled={submittingReport || loading}
|
||||||
className="crm-btn crm-btn-primary mt-6 flex w-full items-center justify-center gap-2 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
|
className={cn(
|
||||||
|
"crm-btn crm-btn-primary mt-6 flex w-full items-center justify-center gap-2 disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
disableMobileMotion ? "active:scale-100" : "active:scale-[0.98]",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Send className="crm-icon-md" />
|
<Send className="crm-icon-md" />
|
||||||
{submittingReport ? "提交中..." : "提交日报"}
|
{submittingReport ? "提交中..." : "提交日报"}
|
||||||
|
|
@ -2010,17 +2076,19 @@ function HistoryDetailModal({
|
||||||
item,
|
item,
|
||||||
onClose,
|
onClose,
|
||||||
onPreviewPhoto,
|
onPreviewPhoto,
|
||||||
|
disableMobileMotion,
|
||||||
}: {
|
}: {
|
||||||
item: WorkHistoryItem;
|
item: WorkHistoryItem;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPreviewPhoto: (url: string, alt: string) => void;
|
onPreviewPhoto: (url: string, alt: string) => void;
|
||||||
|
disableMobileMotion: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[95]">
|
<div className="fixed inset-0 z-[95]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute inset-0 bg-slate-900/45 backdrop-blur-sm"
|
className={cn("absolute inset-0 bg-slate-900/45", !disableMobileMotion && "backdrop-blur-sm")}
|
||||||
aria-label="关闭历史详情"
|
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="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">
|
||||||
|
|
@ -2078,7 +2146,10 @@ function HistoryDetailModal({
|
||||||
key={`${item.id}-detail-photo-${photoIndex}`}
|
key={`${item.id}-detail-photo-${photoIndex}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onPreviewPhoto(photoUrl, `${item.type || "历史"}照片${photoIndex + 1}`)}
|
onClick={() => onPreviewPhoto(photoUrl, `${item.type || "历史"}照片${photoIndex + 1}`)}
|
||||||
className="overflow-hidden rounded-2xl border border-slate-200 bg-white text-left transition-transform hover:scale-[1.01] dark:border-slate-700 dark:bg-slate-900/50"
|
className={cn(
|
||||||
|
"overflow-hidden rounded-2xl border border-slate-200 bg-white text-left dark:border-slate-700 dark:bg-slate-900/50",
|
||||||
|
disableMobileMotion ? "transition-colors" : "transition-transform hover:scale-[1.01]",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<ProtectedImage
|
<ProtectedImage
|
||||||
src={photoUrl}
|
src={photoUrl}
|
||||||
|
|
@ -2108,17 +2179,19 @@ function PhotoPreviewModal({
|
||||||
url,
|
url,
|
||||||
alt,
|
alt,
|
||||||
onClose,
|
onClose,
|
||||||
|
disableMobileMotion,
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
disableMobileMotion: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100]">
|
<div className="fixed inset-0 z-[100]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute inset-0 bg-slate-950/82 backdrop-blur-sm"
|
className={cn("absolute inset-0 bg-slate-950/82", !disableMobileMotion && "backdrop-blur-sm")}
|
||||||
aria-label="关闭照片预览"
|
aria-label="关闭照片预览"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 flex items-center justify-center px-4 py-6">
|
<div className="absolute inset-0 flex items-center justify-center px-4 py-6">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue