企业微信内部定位与密码缓存

main
kangwenjing 2026-03-30 14:29:32 +08:00
parent b574da0a7c
commit 42c21cc4fc
18 changed files with 702 additions and 97 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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
+ "&timestamp=" + 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);
}

View File

@ -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 (

View File

@ -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;
}

View File

@ -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");
}, []);
}

View File

@ -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) {

View File

@ -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,

View File

@ -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}`;
}

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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">

View File

@ -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" />
退

View File

@ -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">