添加签到功能,修复客服链接

This commit is contained in:
gz 2026-07-24 16:04:43 +08:00
parent 02ca6256ec
commit 2009578b80
6 changed files with 394 additions and 22 deletions

View File

@ -17,9 +17,18 @@ function findPressableByText(renderer: ReactTestRenderer, label: string) {
);
}
async function flushPromises(times = 4) {
for (let i = 0; i < times; i += 1) {
await Promise.resolve();
}
}
const {
alertMock,
appStateRef,
dailyCheckInStatusMock,
dailyCheckInSubmitMock,
getAuthTokenMock,
getChannelIdMock,
getVersionCodeMock,
getVersionNameMock,
@ -27,6 +36,7 @@ const {
openPrivacyPolicyMock,
openTermsOfServiceMock,
profileItemsRef,
refreshUserInfoMock,
setClipboardStringMock,
toastErrorMock,
toastSuccessMock,
@ -43,11 +53,42 @@ const {
},
actions: {
resetConnection: vi.fn(),
syncSession: vi.fn(),
toggleCheckIn: vi.fn(),
toggleSourceName: vi.fn(),
},
},
},
dailyCheckInStatusMock: vi.fn(async () => ({
canCheckIn: true,
currentDate: '2026-07-16',
eligible: true,
eligibilityMessage: '可签到',
eligibilityReason: null,
lastSignTime: null,
rewardMinutes: 30,
signTime: null,
timezone: 'Asia/Shanghai',
todayCheckedIn: false,
})),
dailyCheckInSubmitMock: vi.fn(async () => ({
__message: '签到成功',
afterExpiredDate: '2026-07-20 12:30:00',
alreadyCheckedIn: false,
beforeExpiredDate: '2026-07-20 12:00:00',
canCheckIn: false,
checkedIn: true,
currentDate: '2026-07-16',
eligible: true,
eligibilityMessage: '可签到',
eligibilityReason: null,
lastSignTime: '2026-07-16 08:01:02',
rewardMinutes: 30,
signTime: '2026-07-16 08:01:02',
timezone: 'Asia/Shanghai',
todayCheckedIn: true,
})),
getAuthTokenMock: vi.fn(async () => null as string | null),
getChannelIdMock: vi.fn(async () => 'default'),
getVersionCodeMock: vi.fn(async () => '100'),
getVersionNameMock: vi.fn(async () => '1.0.0'),
@ -72,11 +113,13 @@ const {
{ key: 'probe', label: '线路探测', color: '#8E77FF' },
],
},
refreshUserInfoMock: vi.fn(async () => null as any),
setClipboardStringMock: vi.fn(async () => {}),
toastErrorMock: vi.fn(),
toastSuccessMock: vi.fn(),
userInfoRef: {
current: {
refreshUserInfo: vi.fn(async () => null),
userInfo: null as any,
},
},
@ -129,6 +172,26 @@ vi.mock('@telescope/toast', () => ({
}),
}));
vi.mock('@telescope/api-client/react', () => ({
useJsonRpcRequest: (method: string) => {
if (method === 'GetDailyCheckInStatus') {
return {
loading: false,
runAsync: dailyCheckInStatusMock,
};
}
if (method === 'V5DailyCheckIn') {
return {
loading: false,
runAsync: dailyCheckInSubmitMock,
};
}
throw new Error(`Unexpected JSON-RPC method: ${method}`);
},
}));
vi.mock('@telescope/user-info', () => ({
useUserInfo: () => userInfoRef.current,
resolveBoundState: (userInfo: unknown) => Boolean(userInfo),
@ -138,6 +201,10 @@ vi.mock('@telescope/user-info', () => ({
},
}));
vi.mock('@telescope/user-info/auth', () => ({
getAuthToken: getAuthTokenMock,
}));
vi.mock('@telescope/version-info', () => ({
getVersionName: getVersionNameMock,
getVersionCode: getVersionCodeMock,
@ -260,6 +327,7 @@ describe('screens/main/ProfileScreen', () => {
},
actions: {
resetConnection: vi.fn(),
syncSession: vi.fn(),
toggleCheckIn: vi.fn(),
toggleSourceName: vi.fn(),
},
@ -269,8 +337,40 @@ describe('screens/main/ProfileScreen', () => {
navigate: vi.fn(),
};
userInfoRef.current = {
refreshUserInfo: refreshUserInfoMock,
userInfo: null,
};
getAuthTokenMock.mockResolvedValue(null);
refreshUserInfoMock.mockResolvedValue(null);
dailyCheckInStatusMock.mockResolvedValue({
canCheckIn: true,
currentDate: '2026-07-16',
eligible: true,
eligibilityMessage: '可签到',
eligibilityReason: null,
lastSignTime: null,
rewardMinutes: 30,
signTime: null,
timezone: 'Asia/Shanghai',
todayCheckedIn: false,
});
dailyCheckInSubmitMock.mockResolvedValue({
__message: '签到成功',
afterExpiredDate: '2026-07-20 12:30:00',
alreadyCheckedIn: false,
beforeExpiredDate: '2026-07-20 12:00:00',
canCheckIn: false,
checkedIn: true,
currentDate: '2026-07-16',
eligible: true,
eligibilityMessage: '可签到',
eligibilityReason: null,
lastSignTime: '2026-07-16 08:01:02',
rewardMinutes: 30,
signTime: '2026-07-16 08:01:02',
timezone: 'Asia/Shanghai',
todayCheckedIn: true,
});
profileItemsRef.current = [
{ key: 'settings', label: '设置', color: '#5AA6FF' },
{ key: 'notice', label: '通知', color: '#FF6E57' },
@ -402,6 +502,75 @@ describe('screens/main/ProfileScreen', () => {
expect(openPrivacyPolicyMock).not.toHaveBeenCalled();
});
it('点击签到会调用每日签到接口并刷新用户信息', async () => {
const refreshedUserInfo = {
canDailyCheckIn: false,
dailyCheckInDate: '2026-07-16',
dailyCheckInRewardMinutes: 30,
expiredDate: 1784531400,
id: 1,
isTrial: false,
todayCheckedIn: true,
username: 'paid-user',
};
getAuthTokenMock.mockResolvedValue('token-1');
refreshUserInfoMock.mockResolvedValue(refreshedUserInfo);
const { ProfileScreen } = await import('../../../src/screens/main/ProfileScreen');
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<ProfileScreen navigation={navigationRef.current} />);
await flushPromises();
});
const checkInButton = findPressableByText(renderer, '签到');
await act(async () => {
checkInButton?.props.onPress();
await flushPromises();
});
expect(dailyCheckInStatusMock).toHaveBeenCalledWith({ loginToken: 'token-1' });
expect(dailyCheckInSubmitMock).toHaveBeenCalledWith({ loginToken: 'token-1' });
expect(appStateRef.current.actions.toggleCheckIn).not.toHaveBeenCalled();
expect(refreshUserInfoMock).toHaveBeenCalled();
expect(appStateRef.current.actions.syncSession).toHaveBeenCalledWith(refreshedUserInfo);
expect(alertMock).toHaveBeenCalledWith('签到', '签到成功');
});
it('已签到状态会提示今日已签到且不重复提交', async () => {
userInfoRef.current = {
refreshUserInfo: refreshUserInfoMock,
userInfo: {
canDailyCheckIn: false,
dailyCheckInDate: '2026-07-16',
dailyCheckInRewardMinutes: 30,
id: 1,
isTrial: false,
todayCheckedIn: true,
username: 'paid-user',
},
};
const { ProfileScreen } = await import('../../../src/screens/main/ProfileScreen');
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<ProfileScreen navigation={navigationRef.current} />);
await flushPromises();
});
const checkInButton = findPressableByText(renderer, '已签到');
await act(async () => {
checkInButton?.props.onPress();
await flushPromises();
});
expect(dailyCheckInSubmitMock).not.toHaveBeenCalled();
expect(appStateRef.current.actions.toggleCheckIn).not.toHaveBeenCalled();
expect(alertMock).toHaveBeenCalledWith('签到', '今日已签到');
});
it('个人中心菜单会隐藏初始化修复、官网下载和线路探测入口', async () => {
const { ProfileScreen } = await import('../../../src/screens/main/ProfileScreen');
let renderer!: ReactTestRenderer;

View File

@ -128,7 +128,35 @@ describe('screens/main/SharePosterScreen', () => {
expect(nativeShareMock).toHaveBeenCalledWith({
message:
'我正在使用Xingyun网络服务邀请你一起体验稳定连接https://example.com/invite/abc',
'我正在使用行云网络服务邀请你一起体验稳定连接https://example.com/invite/abc',
});
});
it('没有邀请链接时会分享 App Store 地址', async () => {
userInfoRef.current = {
inviteUrl: '',
};
const { SharePosterScreen } = await import('../../../src/screens/main/SharePosterScreen');
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<SharePosterScreen navigation={navigation} />);
});
const shareButton = renderer.root.findAllByType('Pressable' as React.ElementType).find(
node => node
.findAllByType('Text' as React.ElementType)
.some(text => text.props.children === '通过链接分享'),
);
await act(async () => {
shareButton?.props.onPress();
await flushPromises(10);
});
expect(nativeShareMock).toHaveBeenCalledWith({
message:
'我正在使用行云网络服务邀请你一起体验稳定连接https://apps.apple.com/cn/app/%E8%A1%8C%E4%BA%91%E5%8A%A0%E9%80%9F%E5%99%A8-%E5%85%A8%E7%90%83%E7%BD%91%E7%BB%9C%E5%8A%A0%E9%80%9F/id6751933882',
});
});

View File

@ -195,7 +195,7 @@
"TAURI_PLUGIN_PAYMENT_REQUEST_NAME": "ivory-dock",
"TAURI_PLUGIN_CUSTOMER_SERVICE_NAME": "ivory-lane",
"CUSTOMER_SERVICE_URLS": "https://pubtofilegz.oss-rg-china-mainland.aliyuncs.com/kf/gz_kefu.txt,https://wfjsq.s3.ap-east-1.amazonaws.com/gz_kefu.txt,https://raw.githubusercontent.com/ym-source/ym/main/gz_kefu.txt",
"CUSTOMER_SERVICE_ACCOUNT_NAME": "三键连"
"CUSTOMER_SERVICE_ACCOUNT_NAME": "行云加速器"
}
}
}

View File

@ -749,7 +749,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOMER_SERVICE_ACCOUNT_NAME = "三键连";
CUSTOMER_SERVICE_ACCOUNT_NAME = "行云加速器";
CUSTOMER_SERVICE_URLS = "https://pubtofilegz.oss-rg-china-mainland.aliyuncs.com/kf/gz_kefu.txt,https://wfjsq.s3.ap-east-1.amazonaws.com/gz_kefu.txt,https://raw.githubusercontent.com/ym-source/ym/main/gz_kefu.txt";
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 2Y94XJY574;
@ -827,7 +827,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOMER_SERVICE_ACCOUNT_NAME = "三键连";
CUSTOMER_SERVICE_ACCOUNT_NAME = "行云加速器";
CUSTOMER_SERVICE_URLS = "https://pubtofilegz.oss-rg-china-mainland.aliyuncs.com/kf/gz_kefu.txt,https://wfjsq.s3.ap-east-1.amazonaws.com/gz_kefu.txt,https://raw.githubusercontent.com/ym-source/ym/main/gz_kefu.txt";
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 2Y94XJY574;
@ -917,7 +917,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOMER_SERVICE_ACCOUNT_NAME = "三键连";
CUSTOMER_SERVICE_ACCOUNT_NAME = "行云加速器";
CUSTOMER_SERVICE_URLS = "https://pubtofilegz.oss-rg-china-mainland.aliyuncs.com/kf/gz_kefu.txt,https://wfjsq.s3.ap-east-1.amazonaws.com/gz_kefu.txt,https://raw.githubusercontent.com/ym-source/ym/main/gz_kefu.txt";
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 2Y94XJY574;
@ -1008,7 +1008,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CUSTOMER_SERVICE_ACCOUNT_NAME = "三键连";
CUSTOMER_SERVICE_ACCOUNT_NAME = "行云加速器";
CUSTOMER_SERVICE_URLS = "https://pubtofilegz.oss-rg-china-mainland.aliyuncs.com/kf/gz_kefu.txt,https://wfjsq.s3.ap-east-1.amazonaws.com/gz_kefu.txt,https://raw.githubusercontent.com/ym-source/ym/main/gz_kefu.txt";
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 2Y94XJY574;

View File

@ -3,13 +3,19 @@ import { Alert, Image, Modal, Pressable, ScrollView, View } from 'react-native';
import Text from '@telescope/shim-text';
import { setString as setClipboardString } from '@telescope/clipboard';
import { useToast } from '@telescope/toast';
import { useJsonRpcRequest } from '@telescope/api-client/react';
import useMemoizedFn from 'ahooks/es/useMemoizedFn';
import useRequest from 'ahooks/es/useRequest';
import useSafeState from 'ahooks/es/useSafeState';
import type { UserInfo } from '@telescope/shared-types';
import type {
DailyCheckInResponse,
DailyCheckInStatus,
UserInfo,
} from '@telescope/shared-types';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ActionType, logger } from '@telescope/logger';
import { useUserInfo } from '@telescope/user-info';
import { getAuthToken } from '@telescope/user-info/auth';
import { appAssets } from '../../assets';
import type { ProfileItem } from '../../data';
import { profileItems } from '../../data';
@ -22,6 +28,7 @@ import {
import { AppIcon } from '../../components/AppIcons';
import { useAppState } from '../../context/AppState';
import {
getBrowserErrorMessage,
getAppVersionLabel,
getAppVersionLabelAsync,
} from './helpers';
@ -36,6 +43,18 @@ import LineProbeModal from '../../components/LineProbeModal';
const PROFILE_SHORTCUT_KEYS = new Set(['support', 'notice', 'checkIn']);
const HIDDEN_PROFILE_MENU_KEYS = new Set(['repair', 'website', 'probe']);
const DAILY_CHECK_IN_FALLBACK_REWARD_MINUTES = 30;
type DailyCheckInParams = {
loginToken?: string;
};
type DailyCheckInDisplayStatus = {
rewardMinutes?: number;
todayCheckedIn: boolean;
canCheckIn: boolean;
eligibilityMessage?: string | null;
};
const profileShortcutItems: ProfileItem[] = [
{
@ -75,10 +94,81 @@ function getProfileRowLabel(item: ProfileItem) {
return item.key === 'terms' ? '服务协议' : item.label;
}
function resolveDailyCheckInStatusFromUserInfo(
userInfo?: UserInfo | null,
): DailyCheckInDisplayStatus | null {
if (
!userInfo ||
(
userInfo.todayCheckedIn === undefined &&
userInfo.canDailyCheckIn === undefined &&
userInfo.dailyCheckInRewardMinutes === undefined
)
) {
return null;
}
return {
rewardMinutes: userInfo.dailyCheckInRewardMinutes,
todayCheckedIn: userInfo.todayCheckedIn === true,
canCheckIn: userInfo.canDailyCheckIn === true,
};
}
function getDailyCheckInBlockedMessage(status: DailyCheckInDisplayStatus | null): string | null {
if (!status) {
return null;
}
if (status.todayCheckedIn) {
return '今日已签到';
}
if (!status.canCheckIn) {
return status.eligibilityMessage || '当前暂不可签到';
}
return null;
}
function formatDailyCheckInReward(rewardMinutes?: number) {
const minutes = typeof rewardMinutes === 'number' &&
Number.isFinite(rewardMinutes) &&
rewardMinutes > 0
? rewardMinutes
: DAILY_CHECK_IN_FALLBACK_REWARD_MINUTES;
if (minutes % (24 * 60) === 0) {
return `${minutes / (24 * 60)}`;
}
if (minutes % 60 === 0) {
return `${minutes / 60}小时`;
}
return `${minutes}分钟`;
}
function getProfileShortcutLabel(
item: ProfileItem,
dailyCheckInStatus: DailyCheckInDisplayStatus | null,
dailyCheckInSubmitting: boolean,
) {
if (item.key !== 'checkIn') {
return item.label;
}
if (dailyCheckInSubmitting) {
return '签到中';
}
return dailyCheckInStatus?.todayCheckedIn ? '已签到' : item.label;
}
export function ProfileScreen({ navigation }: any) {
const { state, actions } = useAppState();
const toast = useToast();
const { userInfo } = useUserInfo<UserInfo>();
const { userInfo, refreshUserInfo } = useUserInfo<UserInfo>();
const rootNavigation = navigation.getParent?.();
const bound = resolveBoundState(userInfo);
const userLabel = resolveUserDisplayLabel({
@ -87,6 +177,38 @@ export function ProfileScreen({ navigation }: any) {
});
const [bindModalVisible, setBindModalVisible] = useSafeState(false);
const { data: versionLabel = getAppVersionLabel() } = useRequest(getAppVersionLabelAsync);
const [dailyCheckInStatus, setDailyCheckInStatus] = useSafeState<DailyCheckInStatus | null>(null);
const { runAsync: getDailyCheckInStatus } = useJsonRpcRequest<
DailyCheckInParams,
DailyCheckInStatus
>('GetDailyCheckInStatus', undefined, { manual: true });
const { loading: dailyCheckInSubmitting, runAsync: submitDailyCheckIn } = useJsonRpcRequest<
DailyCheckInParams,
DailyCheckInResponse
>('V5DailyCheckIn', undefined, { manual: true });
const userDailyCheckInStatus = useMemo(
() => resolveDailyCheckInStatusFromUserInfo(userInfo),
[userInfo],
);
const effectiveDailyCheckInStatus = dailyCheckInStatus ?? userDailyCheckInStatus;
useRequest(async () => {
const token = await getAuthToken();
if (!token) {
setDailyCheckInStatus(null);
return null;
}
const status = await getDailyCheckInStatus({ loginToken: token });
setDailyCheckInStatus(status);
return status;
}, {
onError: (error) => {
logger.warn('每日签到状态加载失败', {
error: getBrowserErrorMessage(error),
});
},
});
// 判断是否在底部 tab 中访问,避免 tab 页也显示返回按钮。
const parentState = navigation.getParent?.()?.getState?.();
@ -137,7 +259,52 @@ export function ProfileScreen({ navigation }: any) {
setBindModalVisible(true);
};
const onProfileItemFunction = useMemo(() => new Map<string, (item: ProfileItem) => void>([
const handleDailyCheckIn = useMemoizedFn(async () => {
if (dailyCheckInSubmitting) {
return;
}
const blockedMessage = getDailyCheckInBlockedMessage(effectiveDailyCheckInStatus);
if (blockedMessage) {
Alert.alert('签到', blockedMessage);
return;
}
try {
const token = await getAuthToken();
if (!token) {
Alert.alert('签到', '请先登录后再签到');
return;
}
const result = await submitDailyCheckIn({ loginToken: token });
setDailyCheckInStatus(result);
Alert.alert(
'签到',
result.__message ||
(result.alreadyCheckedIn
? '今日已签到'
: `签到成功,已赠送${formatDailyCheckInReward(result.rewardMinutes)}`),
);
try {
const refreshedUserInfo = await refreshUserInfo();
if (refreshedUserInfo) {
actions.syncSession(refreshedUserInfo);
}
} catch (error) {
logger.warn('每日签到后刷新用户信息失败', {
error: getBrowserErrorMessage(error),
});
}
} catch (error) {
const message = getBrowserErrorMessage(error);
logger.warn('每日签到失败', { error: message });
Alert.alert('签到', message);
}
});
const onProfileItemFunction = useMemo(() => new Map<string, (item: ProfileItem) => void | Promise<void>>([
// 导航类
['settings', (item) => navigation.navigate('Settings')],
['notice', (item) => navigation.navigate('Messages')],
@ -148,13 +315,7 @@ export function ProfileScreen({ navigation }: any) {
['logs', (item) => navigation.navigate('CustomerService')],
// 操作类
['checkIn', (item) => {
actions.toggleCheckIn();
Alert.alert(
'签到',
state.checkedIn ? '今日已取消签到。' : '签到成功额外赠送1天时长。',
);
}],
['checkIn', () => handleDailyCheckIn()],
['source', (item) => actions.toggleSourceName()],
['repair', (item) => Alert.alert('初始化修复', '将清除所有本地缓存数据,确定继续吗?', [
{
@ -200,12 +361,17 @@ export function ProfileScreen({ navigation }: any) {
(item) => openBrowserUrl({ url: XINGYUN_WEBSITE_CONFIG.url, source: 'linkable_text' }),
],
['probe', () => setProbeModalVisible(true)],
]), [navigation, state.checkedIn, actions]);
]), [navigation, actions, handleDailyCheckIn]);
// 统一的处理函数
const handleProfileGridPress = useCallback((item: ProfileItem) => {
const handler = onProfileItemFunction.get(item.key);
handler ? handler(item) : Alert.alert('提示', `${item.label}功能暂未开放。`);
if (handler) {
void handler(item);
return;
}
Alert.alert('提示', `${item.label}功能暂未开放。`);
}, [onProfileItemFunction]);
return (
@ -278,6 +444,7 @@ export function ProfileScreen({ navigation }: any) {
<View style={styles.profileShortcutCard}>
{profileShortcutItems.map(item => (
<Pressable
disabled={item.key === 'checkIn' && dailyCheckInSubmitting}
key={item.key}
onPress={() => handleProfileGridPress(item)}
style={styles.profileShortcutItem}
@ -287,7 +454,13 @@ export function ProfileScreen({ navigation }: any) {
source={item.icon}
style={styles.profileShortcutIcon}
/>
<Text style={styles.profileShortcutText}>{item.label}</Text>
<Text style={styles.profileShortcutText}>
{getProfileShortcutLabel(
item,
effectiveDailyCheckInStatus,
dailyCheckInSubmitting,
)}
</Text>
</Pressable>
))}
</View>

View File

@ -18,6 +18,9 @@ import { AppPageHeader } from '../../navigation/AppScreenHeader';
import { getBrowserErrorMessage } from './helpers';
import { styles } from './SharePosterScreen.styles';
const XINGYUN_APP_STORE_URL =
'https://apps.apple.com/cn/app/%E8%A1%8C%E4%BA%91%E5%8A%A0%E9%80%9F%E5%99%A8-%E5%85%A8%E7%90%83%E7%BD%91%E7%BB%9C%E5%8A%A0%E9%80%9F/id6751933882';
export function SharePosterScreen({
navigation,
showBackButton = true,
@ -30,9 +33,8 @@ export function SharePosterScreen({
const handleShare = async () => {
const inviteUrl = userInfo?.inviteUrl?.trim() || '';
const message = inviteUrl
? `我正在使用Xingyun网络服务邀请你一起体验稳定连接${inviteUrl}`
: '我正在使用Xingyun网络服务邀请你一起体验稳定连接。';
const shareUrl = inviteUrl || XINGYUN_APP_STORE_URL;
const message = `我正在使用行云网络服务,邀请你一起体验稳定连接:${shareUrl}`;
try {
logger.action(ActionType.SHARE, { source: 'share_poster_invite', hasInviteUrl: !!inviteUrl });