企业微信内部定位与密码缓存
parent
b574da0a7c
commit
42c21cc4fc
|
|
@ -1,4 +1,3 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="MavenProjectsManager">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.unis.crm.controller;
|
||||
|
||||
import com.unis.crm.common.ApiResponse;
|
||||
import com.unis.crm.dto.wecom.WecomJsSdkConfigDTO;
|
||||
import com.unis.crm.dto.wecom.WecomSsoExchangeRequest;
|
||||
import com.unis.crm.service.WecomSsoService;
|
||||
import com.unisbase.dto.TokenResponse;
|
||||
|
|
@ -43,4 +44,9 @@ public class WecomSsoController {
|
|||
public ApiResponse<TokenResponse> exchange(@Valid @RequestBody WecomSsoExchangeRequest request) {
|
||||
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;
|
||||
|
||||
import com.unis.crm.dto.wecom.WecomJsSdkConfigDTO;
|
||||
import com.unisbase.dto.TokenResponse;
|
||||
|
||||
public interface WecomSsoService {
|
||||
|
|
@ -9,4 +10,6 @@ public interface WecomSsoService {
|
|||
String handleCallback(String code, String state);
|
||||
|
||||
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.unis.crm.common.BusinessException;
|
||||
import com.unis.crm.config.WecomProperties;
|
||||
import com.unis.crm.dto.wecom.WecomJsSdkConfigDTO;
|
||||
import com.unis.crm.service.WecomSsoService;
|
||||
import com.unisbase.auth.JwtTokenProvider;
|
||||
import com.unisbase.common.RedisKeys;
|
||||
|
|
@ -14,7 +15,10 @@ import com.unisbase.mapper.SysUserMapper;
|
|||
import com.unisbase.service.AuthVersionService;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
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_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_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 JSAPI_TICKET_CACHE_KEY = "crm:wecom:jsapi-ticket";
|
||||
private static final String STATE_CACHE_PREFIX = "crm:wecom:sso:state:";
|
||||
private static final String TICKET_CACHE_PREFIX = "crm:wecom:sso:ticket:";
|
||||
|
||||
|
|
@ -145,6 +151,27 @@ public class WecomSsoServiceImpl implements WecomSsoService {
|
|||
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() {
|
||||
if (!wecomProperties.isEnabled()) {
|
||||
throw new BusinessException("企业微信单点登录未启用");
|
||||
|
|
@ -181,6 +208,26 @@ public class WecomSsoServiceImpl implements WecomSsoService {
|
|||
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) {
|
||||
JsonNode response = getJson(WECOM_GET_USER_INFO_URL
|
||||
+ "?access_token=" + urlEncode(accessToken)
|
||||
|
|
@ -377,6 +424,32 @@ public class WecomSsoServiceImpl implements WecomSsoService {
|
|||
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) {
|
||||
return URLEncoder.encode(defaultIfBlank(value, ""), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { cn } from "@/lib/utils";
|
|||
import { useTheme } from "./ThemeProvider";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { getWorkOverview } from "@/lib/auth";
|
||||
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
|
||||
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||
|
||||
type NavChildItem = {
|
||||
name: string;
|
||||
|
|
@ -48,6 +50,9 @@ export default function Layout() {
|
|||
const [afterReminderTime, setAfterReminderTime] = useState(isAfterDailyReportReminderTime());
|
||||
const [mobileBellHint, setMobileBellHint] = useState("");
|
||||
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 activeNavItem = navItems.find((item) => isActivePath(item.path)) ?? navItems[0];
|
||||
const contentKey = activeNavItem.path;
|
||||
|
|
@ -152,9 +157,14 @@ export default function Layout() {
|
|||
</aside>
|
||||
|
||||
{/* 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">
|
||||
<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">
|
||||
<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
|
||||
|
|
@ -166,7 +176,12 @@ export default function Layout() {
|
|||
<button
|
||||
type="button"
|
||||
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 ? "今日日报未提交,前往日报" : "日报提醒,前往日报"}
|
||||
title={hasDailyReportReminderDot ? "今日日报未提交" : "日报提醒"}
|
||||
>
|
||||
|
|
@ -184,7 +199,12 @@ export default function Layout() {
|
|||
) : null}
|
||||
<button
|
||||
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" ? "切换亮色模式" : "切换暗色模式"}
|
||||
>
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
|
|
@ -206,22 +226,33 @@ export default function Layout() {
|
|||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={contentKey}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{isMobileViewport ? (
|
||||
<div key={contentKey}>
|
||||
<Outlet />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
) : (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={contentKey}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Outlet />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 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) => {
|
||||
const isActive = isActivePath(item.path);
|
||||
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;
|
||||
}
|
||||
|
||||
export interface WecomJsSdkConfig {
|
||||
corpId: string;
|
||||
timestamp: number;
|
||||
nonceStr: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface DashboardStat {
|
||||
name: string;
|
||||
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) {
|
||||
const params = new URLSearchParams({ key });
|
||||
if (defaultValue !== undefined) {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export type TencentMapLocation = {
|
|||
longitude: number;
|
||||
address: string;
|
||||
accuracy?: number;
|
||||
sourceType?: "browser" | "tencent";
|
||||
sourceType?: "browser" | "tencent" | "wecom";
|
||||
};
|
||||
|
||||
const EARTH_SEMI_MAJOR_AXIS = 6378245.0;
|
||||
|
|
@ -90,6 +90,14 @@ export async function resolveTencentMapLocation() {
|
|||
throw new Error(buildLocationFailureMessage(browserError, sdkError));
|
||||
}
|
||||
|
||||
export async function resolveBrowserLocation() {
|
||||
return resolveWithBrowser();
|
||||
}
|
||||
|
||||
export async function resolveTencentSdkLocation() {
|
||||
return resolveWithTencentSdk();
|
||||
}
|
||||
|
||||
function mergeLocationAddress(
|
||||
bestResult: TencentMapLocation,
|
||||
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 { useNavigate } from "react-router-dom";
|
||||
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_HISTORY_PREVIEW_COUNT = 3;
|
||||
|
|
@ -23,6 +25,9 @@ const statRoutes: Record<(typeof baseStats)[number]["metricKey"], { pathname: st
|
|||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
const isWecomBrowser = useIsWecomBrowser();
|
||||
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
||||
const [home, setHome] = useState<DashboardHome>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAllActivities, setShowAllActivities] = useState(false);
|
||||
|
|
@ -128,18 +133,18 @@ export default function Dashboard() {
|
|||
{loading ? (
|
||||
<motion.div
|
||||
key="dashboard-skeleton"
|
||||
initial={{ opacity: 0 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||
transition={{ duration: disableMobileMotion ? 0 : 0.22, ease: "easeOut" }}
|
||||
>
|
||||
<DashboardSkeleton />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="dashboard-content"
|
||||
initial={{ opacity: 0 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0 }}
|
||||
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">
|
||||
{stats.map((stat, i) => (
|
||||
|
|
@ -147,11 +152,11 @@ export default function Dashboard() {
|
|||
key={stat.name}
|
||||
type="button"
|
||||
onClick={() => handleStatCardClick(stat.metricKey)}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ 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"
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.1 }}
|
||||
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={disableMobileMotion ? undefined : { scale: 0.98 }}
|
||||
aria-label={`查看${stat.name}`}
|
||||
>
|
||||
<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">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
||||
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"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between sm:mb-4">
|
||||
|
|
@ -266,9 +271,9 @@ export default function Dashboard() {
|
|||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
||||
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"
|
||||
>
|
||||
<h2 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">最新动态</h2>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import {
|
|||
type SalesExpansionItem,
|
||||
} from "@/lib/auth";
|
||||
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
||||
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
|
||||
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ExpansionItem = SalesExpansionItem | ChannelExpansionItem;
|
||||
|
|
@ -257,19 +259,24 @@ function ModalShell({
|
|||
children: ReactNode;
|
||||
footer: ReactNode;
|
||||
}) {
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
const isWecomBrowser = useIsWecomBrowser();
|
||||
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
exit={disableMobileMotion ? undefined : { opacity: 0 }}
|
||||
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
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
|
||||
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"
|
||||
>
|
||||
<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() {
|
||||
const location = useLocation();
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
const isWecomBrowser = useIsWecomBrowser();
|
||||
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
||||
const [activeTab, setActiveTab] = useState<ExpansionTab>("sales");
|
||||
const [selectedItem, setSelectedItem] = useState<ExpansionItem | null>(null);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
|
@ -1020,17 +1030,17 @@ export default function Expansion() {
|
|||
</div>
|
||||
<button
|
||||
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" />
|
||||
<span className="hidden sm:inline">新增</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="crm-filter-bar flex backdrop-blur-sm">
|
||||
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
|
||||
<button
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
|
|
@ -1038,7 +1048,7 @@ export default function Expansion() {
|
|||
</button>
|
||||
<button
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
|
|
@ -1062,12 +1072,12 @@ export default function Expansion() {
|
|||
salesData.length > 0 ? (
|
||||
salesData.map((item, i) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
||||
key={item.id}
|
||||
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="min-w-0">
|
||||
|
|
@ -1100,12 +1110,12 @@ export default function Expansion() {
|
|||
) : channelData.length > 0 ? (
|
||||
channelData.map((item, i) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
||||
key={item.id}
|
||||
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="min-w-0">
|
||||
|
|
|
|||
|
|
@ -15,6 +15,10 @@ import {
|
|||
|
||||
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 = {
|
||||
username: string;
|
||||
password: string;
|
||||
|
|
@ -31,6 +35,56 @@ const DEFAULT_FORM: FormState = {
|
|||
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() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
|
@ -62,9 +116,21 @@ export default function LoginPage() {
|
|||
};
|
||||
|
||||
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) {
|
||||
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());
|
||||
|
||||
if (form.remember) {
|
||||
localStorage.setItem("rememberedUsername", form.username.trim());
|
||||
persistRememberedLogin({
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
tenantCode: form.tenantCode.trim(),
|
||||
});
|
||||
} else {
|
||||
localStorage.removeItem("rememberedUsername");
|
||||
clearRememberedLogin();
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -279,7 +349,7 @@ export default function LoginPage() {
|
|||
checked={form.remember}
|
||||
onChange={(event) => handleChange("remember", event.target.checked)}
|
||||
/>
|
||||
<span>记住用户名</span>
|
||||
<span>记住密码(5天)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Search, Plus, ChevronRight, ChevronDown, Check, Building, Calendar, Dol
|
|||
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 { AdaptiveSelect } from "@/components/AdaptiveSelect";
|
||||
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const detailBadgeClass = "crm-btn-chip text-[11px] font-semibold";
|
||||
|
|
@ -266,10 +267,26 @@ function ModalShell({
|
|||
children: ReactNode;
|
||||
footer: ReactNode;
|
||||
}) {
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
const isWecomBrowser = useIsWecomBrowser();
|
||||
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
||||
|
||||
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 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">
|
||||
<motion.div
|
||||
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="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">
|
||||
|
|
@ -551,6 +568,9 @@ function SearchableSelect({
|
|||
}
|
||||
|
||||
export default function Opportunities() {
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
const isWecomBrowser = useIsWecomBrowser();
|
||||
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
||||
const [archiveTab, setArchiveTab] = useState<OpportunityArchiveTab>("active");
|
||||
const [filter, setFilter] = useState("全部");
|
||||
const [stageFilterOpen, setStageFilterOpen] = useState(false);
|
||||
|
|
@ -850,16 +870,19 @@ export default function Opportunities() {
|
|||
<h1 className="crm-page-title">商机储备</h1>
|
||||
<p className="crm-page-subtitle hidden sm:block">维护商机基础信息,查看关联拓展对象和日报自动回写的跟进记录。</p>
|
||||
</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" />
|
||||
<span className="hidden sm:inline">新增商机</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="crm-filter-bar flex backdrop-blur-sm">
|
||||
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
|
||||
<button
|
||||
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"
|
||||
? "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"
|
||||
|
|
@ -869,7 +892,7 @@ export default function Opportunities() {
|
|||
</button>
|
||||
<button
|
||||
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"
|
||||
? "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"
|
||||
|
|
@ -910,12 +933,12 @@ export default function Opportunities() {
|
|||
{visibleItems.length > 0 ? (
|
||||
visibleItems.map((opp, i) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
transition={disableMobileMotion ? { duration: 0 } : { delay: i * 0.05 }}
|
||||
key={opp.id}
|
||||
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="min-w-0 flex-1">
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import {
|
|||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
|
||||
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
|
||||
import {
|
||||
clearAuth,
|
||||
getCurrentUser,
|
||||
|
|
@ -126,6 +128,9 @@ function PageModal({
|
|||
export default function Profile() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
const isWecomBrowser = useIsWecomBrowser();
|
||||
const disableMobileMotion = isMobileViewport || isWecomBrowser;
|
||||
|
||||
const [overview, setOverview] = useState<ProfileOverview | null>(null);
|
||||
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
|
||||
|
|
@ -345,9 +350,10 @@ export default function Profile() {
|
|||
</header>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||
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
|
||||
onClick={() => void handleOpenProfile()}
|
||||
|
|
@ -375,7 +381,7 @@ export default function Profile() {
|
|||
<button
|
||||
type="button"
|
||||
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="mt-1 text-[11px] text-slate-500 dark:text-slate-400">本月商机</p>
|
||||
|
|
@ -383,7 +389,7 @@ export default function Profile() {
|
|||
<button
|
||||
type="button"
|
||||
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="mt-1 text-[11px] text-slate-500 dark:text-slate-400">本月拓展</p>
|
||||
|
|
@ -396,10 +402,10 @@ export default function Profile() {
|
|||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="crm-card overflow-hidden rounded-2xl transition-all"
|
||||
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.1 }}
|
||||
className="crm-card overflow-hidden rounded-2xl transition-shadow transition-colors"
|
||||
>
|
||||
<ul className="divide-y divide-slate-50 dark:divide-slate-800/50">
|
||||
<li>
|
||||
|
|
@ -446,11 +452,11 @@ export default function Profile() {
|
|||
</motion.div>
|
||||
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
transition={disableMobileMotion ? { duration: 0 } : { delay: 0.2 }}
|
||||
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" />
|
||||
退出登录
|
||||
|
|
|
|||
|
|
@ -28,9 +28,12 @@ import {
|
|||
type WorkTomorrowPlanItem,
|
||||
} from "@/lib/auth";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import { isWecomBrowser, isWecomJsSdkLocationEnabled, resolveWecomLocation } from "@/lib/wecom";
|
||||
|
||||
const reportFieldLabels = {
|
||||
sales: ["沟通内容", "后续规划"],
|
||||
|
|
@ -129,6 +132,9 @@ function getReportStatus(status?: string) {
|
|||
|
||||
export default function Work() {
|
||||
const routerLocation = useLocation();
|
||||
const isMobileViewport = useIsMobileViewport();
|
||||
const isWecomInternalBrowser = useIsWecomBrowser();
|
||||
const disableMobileMotion = isMobileViewport || isWecomInternalBrowser;
|
||||
const currentWorkDate = format(new Date(), "yyyy-MM-dd");
|
||||
const hasAutoRefreshedLocation = useRef(false);
|
||||
const photoInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
|
@ -403,7 +409,36 @@ export default function Work() {
|
|||
|
||||
setLocationHint("正在获取当前位置...");
|
||||
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 longitude = position.longitude;
|
||||
const displayName = await resolveLocationDisplayName(latitude, longitude, position.address);
|
||||
|
|
@ -417,7 +452,11 @@ export default function Work() {
|
|||
setLocationAccuracyMeters(position.accuracy);
|
||||
setLocationAdjustmentConfirmed(false);
|
||||
const accuracyText = position.accuracy ? `当前精度约 ${Math.round(position.accuracy)} 米` : "定位已刷新";
|
||||
const sourceText = position.sourceType === "tencent" ? "腾讯定位" : "浏览器定位";
|
||||
const sourceText = position.sourceType === "wecom"
|
||||
? "企业微信定位"
|
||||
: position.sourceType === "tencent"
|
||||
? "腾讯定位"
|
||||
: "浏览器定位";
|
||||
setLocationHint(
|
||||
position.accuracy && position.accuracy > CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS
|
||||
? `${sourceText} ${accuracyText},当前精度超过 ${CHECK_IN_ACCURACY_CONFIRM_THRESHOLD_METERS} 米,不能直接打卡,请先重新定位或调整位置确认。`
|
||||
|
|
@ -743,7 +782,7 @@ export default function Work() {
|
|||
</div>
|
||||
</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={`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}
|
||||
submittingCheckIn={submittingCheckIn}
|
||||
onSubmit={() => void handleCheckInSubmit()}
|
||||
disableMobileMotion={disableMobileMotion}
|
||||
/>
|
||||
) : (
|
||||
<ReportPanel
|
||||
|
|
@ -802,6 +842,7 @@ export default function Work() {
|
|||
pageError={pageError}
|
||||
submittingReport={submittingReport}
|
||||
onSubmit={() => void handleReportSubmit()}
|
||||
disableMobileMotion={disableMobileMotion}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -825,6 +866,7 @@ export default function Work() {
|
|||
index={index}
|
||||
onOpen={() => setHistoryDetailItem(item)}
|
||||
onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })}
|
||||
disableMobileMotion={disableMobileMotion}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -849,12 +891,15 @@ export default function Work() {
|
|||
initial={false}
|
||||
animate={{
|
||||
opacity: showHistoryBackToTop ? 1 : 0,
|
||||
scale: showHistoryBackToTop ? 1 : 0.92,
|
||||
y: showHistoryBackToTop ? 0 : 8,
|
||||
scale: disableMobileMotion ? 1 : showHistoryBackToTop ? 1 : 0.92,
|
||||
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" })}
|
||||
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" }}
|
||||
aria-label="回到历史记录顶部"
|
||||
>
|
||||
|
|
@ -869,7 +914,7 @@ export default function Work() {
|
|||
<button
|
||||
type="button"
|
||||
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="关闭选择对象"
|
||||
/>
|
||||
<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 }}
|
||||
radius={LOCATION_ADJUST_RADIUS_METERS}
|
||||
currentAddress={checkInForm.locationText}
|
||||
disableMobileMotion={disableMobileMotion}
|
||||
onClose={() => setLocationAdjustOpen(false)}
|
||||
onConfirm={(point, address) => void handleApplyLocationAdjust(point, address)}
|
||||
/>
|
||||
|
|
@ -962,6 +1008,7 @@ export default function Work() {
|
|||
item={historyDetailItem}
|
||||
onClose={() => setHistoryDetailItem(null)}
|
||||
onPreviewPhoto={(url, alt) => setPreviewPhoto({ url, alt })}
|
||||
disableMobileMotion={disableMobileMotion}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
|
@ -970,6 +1017,7 @@ export default function Work() {
|
|||
url={previewPhoto.url}
|
||||
alt={previewPhoto.alt}
|
||||
onClose={() => setPreviewPhoto(null)}
|
||||
disableMobileMotion={disableMobileMotion}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -983,6 +1031,7 @@ function LocationAdjustModal({
|
|||
currentAddress,
|
||||
onClose,
|
||||
onConfirm,
|
||||
disableMobileMotion,
|
||||
}: {
|
||||
origin: LocationPoint;
|
||||
currentPoint: LocationPoint;
|
||||
|
|
@ -990,6 +1039,7 @@ function LocationAdjustModal({
|
|||
currentAddress: string;
|
||||
onClose: () => void;
|
||||
onConfirm: (point: LocationPoint, address: string) => void;
|
||||
disableMobileMotion: boolean;
|
||||
}) {
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<{
|
||||
|
|
@ -1166,7 +1216,7 @@ function LocationAdjustModal({
|
|||
<button
|
||||
type="button"
|
||||
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="关闭调整位置"
|
||||
/>
|
||||
<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({
|
||||
activeWorkSection,
|
||||
disableMobileMotion,
|
||||
}: {
|
||||
activeWorkSection: WorkSection;
|
||||
disableMobileMotion: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="crm-filter-bar flex backdrop-blur-sm">
|
||||
<div className={cn("crm-filter-bar flex", !disableMobileMotion && "backdrop-blur-sm")}>
|
||||
{workSectionItems.map((item) => {
|
||||
const isActive = item.key === activeWorkSection;
|
||||
return (
|
||||
|
|
@ -1264,7 +1316,7 @@ function WorkSectionNav({
|
|||
key={item.path}
|
||||
to={item.path}
|
||||
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
|
||||
? "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"
|
||||
|
|
@ -1291,7 +1343,7 @@ function MobilePanelToggle({
|
|||
type="button"
|
||||
onClick={() => onChange("entry")}
|
||||
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"
|
||||
? "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"
|
||||
|
|
@ -1303,7 +1355,7 @@ function MobilePanelToggle({
|
|||
type="button"
|
||||
onClick={() => onChange("history")}
|
||||
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"
|
||||
? "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"
|
||||
|
|
@ -1387,17 +1439,19 @@ function HistoryCard({
|
|||
index,
|
||||
onOpen,
|
||||
onPreviewPhoto,
|
||||
disableMobileMotion,
|
||||
}: {
|
||||
item: WorkHistoryItem;
|
||||
index: number;
|
||||
onOpen: () => void;
|
||||
onPreviewPhoto: (url: string, alt: string) => void;
|
||||
disableMobileMotion: boolean;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.06 }}
|
||||
transition={disableMobileMotion ? { duration: 0 } : { delay: index * 0.06 }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
|
|
@ -1407,7 +1461,7 @@ function HistoryCard({
|
|||
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="flex items-center gap-3">
|
||||
|
|
@ -1492,6 +1546,7 @@ function CheckInPanel({
|
|||
pageError,
|
||||
submittingCheckIn,
|
||||
onSubmit,
|
||||
disableMobileMotion,
|
||||
}: {
|
||||
loading: boolean;
|
||||
checkInForm: CreateWorkCheckInPayload;
|
||||
|
|
@ -1515,14 +1570,16 @@ function CheckInPanel({
|
|||
pageError: string;
|
||||
submittingCheckIn: boolean;
|
||||
onSubmit: () => void;
|
||||
disableMobileMotion: boolean;
|
||||
}) {
|
||||
const mobileCameraOnly = supportsMobileCameraCapture();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="checkin"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={disableMobileMotion ? { duration: 0 } : undefined}
|
||||
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">
|
||||
|
|
@ -1660,7 +1717,7 @@ function CheckInPanel({
|
|||
value={checkInForm.remark ?? ""}
|
||||
onChange={(event) => onRemarkChange(event.target.value)}
|
||||
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>
|
||||
|
||||
|
|
@ -1672,7 +1729,10 @@ function CheckInPanel({
|
|||
type="button"
|
||||
onClick={onSubmit}
|
||||
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" />
|
||||
{submittingCheckIn ? "提交中..." : requiresLocationConfirmation ? "请先确认位置" : "确认打卡"}
|
||||
|
|
@ -1699,6 +1759,7 @@ function ReportPanel({
|
|||
pageError,
|
||||
submittingReport,
|
||||
onSubmit,
|
||||
disableMobileMotion,
|
||||
}: {
|
||||
loading: boolean;
|
||||
reportStatus?: string;
|
||||
|
|
@ -1717,6 +1778,7 @@ function ReportPanel({
|
|||
pageError: string;
|
||||
submittingReport: boolean;
|
||||
onSubmit: () => void;
|
||||
disableMobileMotion: boolean;
|
||||
}) {
|
||||
const [editingReportLineIndex, setEditingReportLineIndex] = useState<number | null>(null);
|
||||
const [editingPlanItemIndex, setEditingPlanItemIndex] = useState<number | null>(null);
|
||||
|
|
@ -1824,8 +1886,9 @@ function ReportPanel({
|
|||
return (
|
||||
<motion.div
|
||||
key="report"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
initial={disableMobileMotion ? false : { opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={disableMobileMotion ? { duration: 0 } : undefined}
|
||||
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">
|
||||
|
|
@ -1885,13 +1948,13 @@ function ReportPanel({
|
|||
}}
|
||||
onBlur={() => setEditingReportLineIndex((current) => (current === index ? null : current))}
|
||||
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
|
||||
type="button"
|
||||
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">
|
||||
{collapsedPreviewLines.map((line, lineIndex) => (
|
||||
|
|
@ -1955,13 +2018,13 @@ function ReportPanel({
|
|||
onChange={(event) => onPlanItemChange(index, event.target.value)}
|
||||
onBlur={() => setEditingPlanItemIndex((current) => (current === index ? null : current))}
|
||||
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
|
||||
type="button"
|
||||
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
|
||||
className={cn(
|
||||
|
|
@ -1997,7 +2060,10 @@ function ReportPanel({
|
|||
type="button"
|
||||
onClick={onSubmit}
|
||||
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" />
|
||||
{submittingReport ? "提交中..." : "提交日报"}
|
||||
|
|
@ -2010,17 +2076,19 @@ function HistoryDetailModal({
|
|||
item,
|
||||
onClose,
|
||||
onPreviewPhoto,
|
||||
disableMobileMotion,
|
||||
}: {
|
||||
item: WorkHistoryItem;
|
||||
onClose: () => void;
|
||||
onPreviewPhoto: (url: string, alt: string) => void;
|
||||
disableMobileMotion: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[95]">
|
||||
<button
|
||||
type="button"
|
||||
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="关闭历史详情"
|
||||
/>
|
||||
<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}`}
|
||||
type="button"
|
||||
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
|
||||
src={photoUrl}
|
||||
|
|
@ -2108,17 +2179,19 @@ function PhotoPreviewModal({
|
|||
url,
|
||||
alt,
|
||||
onClose,
|
||||
disableMobileMotion,
|
||||
}: {
|
||||
url: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
disableMobileMotion: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
<button
|
||||
type="button"
|
||||
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="关闭照片预览"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4 py-6">
|
||||
|
|
|
|||
Loading…
Reference in New Issue