diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index ac3139b..7824862 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -48,7 +48,7 @@ jobs: - name: Resolve runtime version id: runtime run: | - RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")" + RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")" echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT" echo "Resolved runtimeVersion: ${RUNTIME_VERSION}" @@ -276,7 +276,7 @@ jobs: - name: Resolve runtime version id: runtime run: | - RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")" + RUNTIME_VERSION="$(node -p "require('./app.json').expo.runtimeVersion")" echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT" echo "Resolved runtimeVersion: ${RUNTIME_VERSION}" diff --git a/Dockerfile.web b/Dockerfile.web index 4bc995f..b6cd039 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -2,14 +2,11 @@ FROM node:25-alpine AS builder WORKDIR /app -# git required by app.config.js for version derivation (has try-catch fallback) -RUN apk add --no-cache git - COPY package.json package-lock.json ./ RUN npm ci COPY . . -RUN NODE_OPTIONS="--max-old-space-size=4096" node /app/node_modules/.bin/expo export --platform web --output-dir dist-web +RUN node /app/node_modules/.bin/expo export --platform web --output-dir dist-web FROM nginx:1.27-alpine diff --git a/app.config.js b/app.config.js index 7a14edb..7abd266 100644 --- a/app.config.js +++ b/app.config.js @@ -6,15 +6,21 @@ const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1'; const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn'; const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1'; -function getGitShortHash() { +function getCommitCount() { try { - return execSync('git rev-parse --short=4 HEAD', { encoding: 'utf-8' }).trim(); + return execSync('git rev-list --count HEAD', { encoding: 'utf-8' }).trim(); } catch { - return '0000'; + return '1'; } } -const gitBuildSuffix = getGitShortHash(); +// 在 commit count 上加偏移,保证 versionCode / buildNumber / runtimeVersion +// 始终大于历史最大值(之前的最后一个版本是 5547),避免被系统/Play Store +// 误判为降级。后续即使 commit count 重置或换仓库,偏移也能保证单调递增。 +const BUILD_NUMBER_OFFSET = 10000; + +const commitCount = getCommitCount(); +const buildNumber = String(parseInt(commitCount, 10) + BUILD_NUMBER_OFFSET); function toManifestUrl(baseUrl, portOverride) { const parsed = new URL(baseUrl); @@ -60,9 +66,9 @@ const filteredPlugins = isWeb module.exports = { ...expo, name: isDevVariant ? `${expo.name} Dev` : expo.name, - runtimeVersion: { - policy: 'appVersion', - }, + // runtimeVersion 用 build number(commit count + 偏移):单调递增、纯数字、与 version (语义版本) 解耦 + // 字符串形式,等价于 policy: 'custom' + runtimeVersion: buildNumber, updates: { ...(expo.updates || {}), url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl, @@ -71,12 +77,12 @@ module.exports = { }, ios: { ...expo.ios, - buildNumber: gitBuildSuffix, + buildNumber: buildNumber, }, android: { ...expo.android, package: 'cn.qczlit.withyou', - versionCode: parseInt(gitBuildSuffix, 16), + versionCode: parseInt(buildNumber, 10), }, // Web 端使用过滤后的插件 plugins: filteredPlugins, diff --git a/app.json b/app.json index b256186..ae786b4 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "威友", "slug": "qojo", - "version": "1.0.0", + "version": "1.0.1", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -36,7 +36,6 @@ }, "predictiveBackGestureEnabled": false, "package": "cn.qczlit.withyou", - "versionCode": 7, "permissions": [ "VIBRATE", "RECORD_AUDIO", @@ -53,7 +52,6 @@ "android.permission.READ_EXTERNAL_STORAGE", "android.permission.WRITE_EXTERNAL_STORAGE", "android.permission.READ_MEDIA_VISUAL_USER_SELECTED", - "android.permission.ACCESS_MEDIA_LOCATION", "android.permission.READ_MEDIA_IMAGES", "android.permission.READ_MEDIA_VIDEO", "android.permission.READ_MEDIA_AUDIO", @@ -108,7 +106,7 @@ { "photosPermission": "允许威友访问您的照片以发布内容", "savePhotosPermission": "允许威友保存照片到您的相册", - "isAccessMediaLocationEnabled": true + "isAccessMediaLocationEnabled": false } ], [ @@ -157,6 +155,8 @@ } ], "./plugins/withHuaweiPush", + "./plugins/withXiaomiPush", + "./plugins/withHonorPush", "expo-callkit-telecom", [ "expo-splash-screen", diff --git a/package-lock.json b/package-lock.json index 3ab5379..18b7ff5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "with_you", - "version": "0.0.2", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "with_you", - "version": "0.0.2", + "version": "1.0.0", "dependencies": { "@expo/ui": "~56.0.17", "@expo/vector-icons": "^15.1.1", @@ -24,6 +24,7 @@ "expo-camera": "~56.0.8", "expo-constants": "~56.0.18", "expo-dev-client": "~56.0.20", + "expo-document-picker": "~56.0.4", "expo-file-system": "~56.0.8", "expo-font": "~56.0.6", "expo-haptics": "~56.0.3", @@ -5530,6 +5531,15 @@ "expo": "*" } }, + "node_modules/expo-document-picker": { + "version": "56.0.4", + "resolved": "https://registry.npmmirror.com/expo-document-picker/-/expo-document-picker-56.0.4.tgz", + "integrity": "sha512-75Apf74XNkYYohObIH19VZw42xpe0gmEnPccuzGXKVAzlvTYCfibSgW17F+6vt4paOfZEnAoZ1QFZM6dmaujRA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-eas-client": { "version": "56.0.1", "resolved": "https://registry.npmmirror.com/expo-eas-client/-/expo-eas-client-56.0.1.tgz", diff --git a/package.json b/package.json index bcba61a..530bb9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "with_you", - "version": "1.0.0", + "version": "1.0.1", "main": "expo-router/entry", "scripts": { "start": "expo start", @@ -30,6 +30,7 @@ "expo-camera": "~56.0.8", "expo-constants": "~56.0.18", "expo-dev-client": "~56.0.20", + "expo-document-picker": "~56.0.4", "expo-file-system": "~56.0.8", "expo-font": "~56.0.6", "expo-haptics": "~56.0.3", diff --git a/plugins/withHonorPush.js b/plugins/withHonorPush.js new file mode 100644 index 0000000..0982e6e --- /dev/null +++ b/plugins/withHonorPush.js @@ -0,0 +1,205 @@ +const { + withProjectBuildGradle, + withSettingsGradle, + withDangerousMod, +} = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +// 荣耀厂商推送通道的 Expo config plugin. +// +// 客户端职责(依据极光官方文档 https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide): +// 1) 添加 cn.jiguang.sdk.plugin:honor 依赖 +// 2) 注入 manifestPlaceholders: HONOR_APPID +// 3) 添加荣耀 Maven 仓库 https://developer.hihonor.com/repo (v5.9.0+ 必需) +// 4) 添加 Proguard 规则 (com.hihonor.push.** 保留 + 5 条 -keepattributes) +// +// 实现说明:app/build.gradle 改动用 withDangerousMod 直接读写磁盘文件。 +// 原因:Expo SDK 56+ 的 withAppBuildGradle hook 链中,第二个以后的 plugin 写回 +// modResults.contents 会被静默忽略,导致多个 vendor plugin 只能写入第一个的修改。 +// 仓库(settings.gradle / build.gradle)改动用 withProjectBuildGradle / withSettingsGradle +// 没有这个问题。 +const withHonorPush = (config, options = {}) => { + const { + jpushVersion = '5.8.0', + appId = '104562917', + } = options; + + const honorRepoUrl = 'https://developer.hihonor.com/repo'; + + // 1. settings.gradle: 添加荣耀 Maven 仓库 + config = withSettingsGradle(config, (config) => { + let contents = config.modResults.contents; + + if (!contents.includes('developer.hihonor.com/repo')) { + const repoLine = ` maven { url '${honorRepoUrl}' }`; + const hasRepositoriesInPM = /pluginManagement\s*\{[\s\S]*?repositories\s*\{/.test(contents); + + if (hasRepositoriesInPM) { + config.modResults.contents = contents.replace( + /(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/, + `$1\n ${repoLine}` + ); + } else if (contents.includes('pluginManagement')) { + config.modResults.contents = contents.replace( + /(pluginManagement\s*\{)/, + `$1\n repositories {\n gradlePluginPortal()\n google()\n mavenCentral()\n ${repoLine}\n }` + ); + } else { + config.modResults.contents = + `pluginManagement {\n repositories {\n ${repoLine}\n }\n}\n\n` + + config.modResults.contents; + } + } + + // Gradle 9+ layout + if ( + !config.modResults.contents.match( + /dependencyResolutionManagement[\s\S]*developer\.hihonor\.com\/repo/ + ) + ) { + const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/; + if (depMgmtPattern.test(config.modResults.contents)) { + config.modResults.contents = config.modResults.contents.replace( + depMgmtPattern, + `$&\n maven { url '${honorRepoUrl}' }` + ); + } + } + + return config; + }); + + // 2. 根 build.gradle: 添加荣耀 Maven 仓库到 buildscript.repositories + config = withProjectBuildGradle(config, (config) => { + let contents = config.modResults.contents; + + if (!contents.includes('developer.hihonor.com/repo')) { + const bsRepoPattern = /(buildscript\s*\{[\s\S]*?repositories\s*\{)/; + if (bsRepoPattern.test(contents)) { + contents = contents.replace( + bsRepoPattern, + `$1\n maven { url '${honorRepoUrl}' }` + ); + } + } + + config.modResults.contents = contents; + return config; + }); + + // 3. app/build.gradle: 注入 AAR 依赖 + manifestPlaceholders (用 withDangerousMod 直接读文件) + config = withDangerousMod(config, [ + 'android', + async (config) => { + const appBuildGradlePath = path.join( + config.modRequest.platformProjectRoot, + 'app', + 'build.gradle' + ); + const proguardFilePath = path.join( + config.modRequest.platformProjectRoot, + 'app', + 'proguard-rules.pro' + ); + + // --- 3a. 处理 build.gradle --- + if (fs.existsSync(appBuildGradlePath)) { + let gradle = fs.readFileSync(appBuildGradlePath, 'utf-8'); + let changed = false; + + // 注入 cn.jiguang.sdk.plugin:honor 依赖 + if (!gradle.includes('cn.jiguang.sdk.plugin:honor')) { + const depsPattern = /dependencies\s*\{/; + if (depsPattern.test(gradle)) { + gradle = gradle.replace( + depsPattern, + `dependencies {\n implementation 'cn.jiguang.sdk.plugin:honor:${jpushVersion}'` + ); + changed = true; + } + } + + // 注入 manifestPlaceholders: HONOR_APPID + const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/; + const match = gradle.match(manifestPlaceholderRegex); + + const requiredPlaceholders = { + HONOR_APPID: appId, + }; + + if (match) { + const existingBlock = match[1]; + const existingEntries = {}; + const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g; + let entryMatch; + while ((entryMatch = entryRegex.exec(existingBlock)) !== null) { + existingEntries[entryMatch[1]] = entryMatch[2]; + } + + let placeholdersChanged = false; + for (const [key, value] of Object.entries(requiredPlaceholders)) { + if (existingEntries[key] !== value) { + existingEntries[key] = value; + placeholdersChanged = true; + } + } + + if (placeholdersChanged) { + const entriesStr = Object.entries(existingEntries) + .map(([k, v]) => `${k}: "${v}"`) + .join(',\n '); + const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`; + gradle = gradle.replace(manifestPlaceholderRegex, newBlock.trim()); + changed = true; + } + } else if (gradle.includes('REACT_NATIVE_RELEASE_LEVEL')) { + // 没有 manifestPlaceholders block 时新建 + const entriesStr = Object.entries(requiredPlaceholders) + .map(([k, v]) => `${k}: "${v}"`) + .join(',\n '); + const block = `\n manifestPlaceholders = [\n ${entriesStr}\n ]\n`; + const lines = gradle.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) { + lines.splice(i + 1, 0, block); + break; + } + } + gradle = lines.join('\n'); + changed = true; + } + + if (changed) { + fs.writeFileSync(appBuildGradlePath, gradle); + console.log('[withHonorPush] updated app/build.gradle (AAR + manifestPlaceholders)'); + } + } + + // --- 3b. 处理 proguard-rules.pro --- + if (fs.existsSync(proguardFilePath)) { + let proguard = fs.readFileSync(proguardFilePath, 'utf-8'); + if (!proguard.includes('-keep class com.hihonor.push.**')) { + const honorRules = ` +# JPush Honor vendor channel — keep rules (https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide) +-ignorewarnings +-keepattributes *Annotation* +-keepattributes Exceptions +-keepattributes InnerClasses +-keepattributes Signature +-keepattributes SourceFile,LineNumberTable +-keep class com.hihonor.push.**{*;} +`; + fs.writeFileSync(proguardFilePath, proguard + honorRules); + console.log('[withHonorPush] appended Honor Proguard rules to proguard-rules.pro'); + } + } + + return config; + }, + ]); + + return config; +}; + +module.exports = withHonorPush; diff --git a/plugins/withXiaomiPush.js b/plugins/withXiaomiPush.js new file mode 100644 index 0000000..1557ce7 --- /dev/null +++ b/plugins/withXiaomiPush.js @@ -0,0 +1,133 @@ +const { withDangerousMod } = require('@expo/config-plugins'); +const fs = require('fs'); +const path = require('path'); + +// 小米厂商推送通道的 Expo config plugin. +// +// 客户端职责(依据极光官方文档 https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide): +// 1) 添加 cn.jiguang.sdk.plugin:xiaomi 依赖 +// 2) 注入 manifestPlaceholders: XIAOMI_APPKEY / XIAOMI_APPID +// 3) 添加 Proguard 规则 (com.xiaomi.push.** 保留) +// +// 实现说明:用 withDangerousMod 直接读写磁盘文件。 +// 原因:Expo SDK 56+ 的 withAppBuildGradle hook 链中,第二个以后的 plugin 写回 +// modResults.contents 会被静默忽略(基于 withAndroidProjectBuildGradleBaseMod), +// 导致多个 vendor plugin 只能写入第一个的修改。withDangerousMod 走文件系统, +// 串行、可靠,幂等检查保证可重复执行 prebuild。 +const withXiaomiPush = (config, options = {}) => { + const { + jpushVersion = '5.8.0', + appId = '2882303761520539252', + appKey = '5792053978252', + } = options; + + config = withDangerousMod(config, [ + 'android', + async (config) => { + const appBuildGradlePath = path.join( + config.modRequest.platformProjectRoot, + 'app', + 'build.gradle' + ); + const proguardFilePath = path.join( + config.modRequest.platformProjectRoot, + 'app', + 'proguard-rules.pro' + ); + + // --- 1. 处理 build.gradle --- + if (fs.existsSync(appBuildGradlePath)) { + let gradle = fs.readFileSync(appBuildGradlePath, 'utf-8'); + let changed = false; + + // 1a. 注入 AAR 依赖 + if (!gradle.includes('cn.jiguang.sdk.plugin:xiaomi')) { + const depsPattern = /dependencies\s*\{/; + if (depsPattern.test(gradle)) { + gradle = gradle.replace( + depsPattern, + `dependencies {\n implementation 'cn.jiguang.sdk.plugin:xiaomi:${jpushVersion}'` + ); + changed = true; + } + } + + // 1b. 注入 manifestPlaceholders + const manifestPlaceholderRegex = /manifestPlaceholders\s*=\s*\[([\s\S]*?)\]/; + const match = gradle.match(manifestPlaceholderRegex); + + const requiredPlaceholders = { + XIAOMI_APPKEY: appKey, + XIAOMI_APPID: appId, + }; + + if (match) { + const existingBlock = match[1]; + const existingEntries = {}; + const entryRegex = /(\w+)\s*:\s*"([^"]*)"/g; + let entryMatch; + while ((entryMatch = entryRegex.exec(existingBlock)) !== null) { + existingEntries[entryMatch[1]] = entryMatch[2]; + } + + let placeholdersChanged = false; + for (const [key, value] of Object.entries(requiredPlaceholders)) { + if (existingEntries[key] !== value) { + existingEntries[key] = value; + placeholdersChanged = true; + } + } + + if (placeholdersChanged) { + const entriesStr = Object.entries(existingEntries) + .map(([k, v]) => `${k}: "${v}"`) + .join(',\n '); + const newBlock = `\n manifestPlaceholders = [\n ${entriesStr}\n ]`; + gradle = gradle.replace(manifestPlaceholderRegex, newBlock.trim()); + changed = true; + } + } else if (gradle.includes('REACT_NATIVE_RELEASE_LEVEL')) { + // 没有 manifestPlaceholders block 时新建 + const entriesStr = Object.entries(requiredPlaceholders) + .map(([k, v]) => `${k}: "${v}"`) + .join(',\n '); + const block = `\n manifestPlaceholders = [\n ${entriesStr}\n ]\n`; + const lines = gradle.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) { + lines.splice(i + 1, 0, block); + break; + } + } + gradle = lines.join('\n'); + changed = true; + } + + if (changed) { + fs.writeFileSync(appBuildGradlePath, gradle); + console.log('[withXiaomiPush] updated app/build.gradle (AAR + manifestPlaceholders)'); + } + } + + // --- 2. 处理 proguard-rules.pro --- + if (fs.existsSync(proguardFilePath)) { + let proguard = fs.readFileSync(proguardFilePath, 'utf-8'); + if (!proguard.includes('-keep class com.xiaomi.push.**')) { + const xiaomiRules = ` +# JPush Xiaomi vendor channel — keep rules (https://docs.jiguang.cn/jpush/client/Android/android_3rd_guide) +-dontwarn com.xiaomi.push.** +-keep class com.xiaomi.push.** { *; } +`; + fs.writeFileSync(proguardFilePath, proguard + xiaomiRules); + console.log('[withXiaomiPush] appended Xiaomi Proguard rules to proguard-rules.pro'); + } + } + + return config; + }, + ]); + + return config; +}; + +module.exports = withXiaomiPush; diff --git a/src/components/business/PostMentionInput.tsx b/src/components/business/PostMentionInput.tsx index f443c59..9f364c8 100644 --- a/src/components/business/PostMentionInput.tsx +++ b/src/components/business/PostMentionInput.tsx @@ -4,7 +4,7 @@ * 检测输入中的 @ 字符,弹出关注者列表供选择 */ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react'; import { View, TextInput, @@ -51,7 +51,12 @@ interface PostMentionInputProps { returnKeyType?: 'default' | 'send' | 'done'; } -const PostMentionInput: React.FC = ({ +export interface PostMentionInputHandle { + focus: () => void; + blur: () => void; +} + +const PostMentionInput = forwardRef(({ value, onChangeText, onSegmentsChange, @@ -63,7 +68,7 @@ const PostMentionInput: React.FC = ({ onPostRefPasted, onSubmitEditing, returnKeyType = 'default', -}) => { +}, ref) => { const colors = useAppColors(); const currentUser = useAuthStore(s => s.currentUser); const [followingUsers, setFollowingUsers] = useState([]); @@ -75,6 +80,21 @@ const PostMentionInput: React.FC = ({ const [postSearchTimer, setPostSearchTimer] = useState | null>(null); const inputRef = useRef(null); + // 暴露 focus / blur 方法给父组件,用于在模式切换(如点击回复)后自动唤起键盘 + useImperativeHandle(ref, () => ({ + focus: () => { + // 展开动画/挂载需要时间,延迟一帧后再聚焦,避免在折叠态未真正展开时调用无效 + requestAnimationFrame(() => { + setTimeout(() => { + inputRef.current?.focus(); + }, 50); + }); + }, + blur: () => { + inputRef.current?.blur(); + }, + }), []); + useEffect(() => { if (!loaded && currentUser?.id) { loadFollowing(); @@ -361,7 +381,7 @@ const PostMentionInput: React.FC = ({ )} ); -}; +}); const styles = StyleSheet.create({ container: { diff --git a/src/components/business/index.ts b/src/components/business/index.ts index d44eeeb..134f99c 100644 --- a/src/components/business/index.ts +++ b/src/components/business/index.ts @@ -24,6 +24,7 @@ export { default as VoteEditor } from './VoteEditor'; export { default as VotePreview } from './VotePreview'; export { default as PostContentRenderer } from './PostContentRenderer'; export { default as PostMentionInput } from './PostMentionInput'; +export type { PostMentionInputHandle } from './PostMentionInput'; export { default as BlockEditor } from './BlockEditor'; export type { BlockEditorHandle } from './BlockEditor'; export { default as ReportDialog } from './ReportDialog'; diff --git a/src/hooks/useRegisterPushDevice.ts b/src/hooks/useRegisterPushDevice.ts index f17d703..e8ab566 100644 --- a/src/hooks/useRegisterPushDevice.ts +++ b/src/hooks/useRegisterPushDevice.ts @@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react'; import { Platform } from 'react-native'; import { jpushService } from '../services/notification/jpushService'; -import { isAutoStartAllowed } from '../services/consent/autoStartConsent'; export function useRegisterPushDevice(isAuthenticated: boolean, userID?: string) { const deviceRegistered = useRef(false); @@ -10,12 +9,12 @@ export function useRegisterPushDevice(isAuthenticated: boolean, userID?: string) useEffect(() => { if (Platform.OS === 'web' || !isAuthenticated || !userID) return; - // 仅在用户同意自启动后才初始化 JPush - if (!isAutoStartAllowed()) { - console.log('[useRegisterPushDevice] 用户未同意自启动,跳过 JPush 初始化'); - return; - } - + // JPush 初始化只取决于登录态——这是基础推送通道(获取 RegistrationID、 + // 向服务器注册设备 token),与"是否允许后台自启动/保活"无关。 + // 后台保活由 backgroundService 控制;退后台是否保持 JPush 长连接由 + // jpushService._setBackgroundKeepLongConn() 根据 autoStart 同意状态自行处理。 + // 若在此处用 consent 拦截 init,未同意用户将永远拿不到 RegistrationID, + // 即便在前台/已授予通知权限也无法收到推送。 if (!deviceRegistered.current) { deviceRegistered.current = true; diff --git a/src/screens/home/PostDetailScreen.tsx b/src/screens/home/PostDetailScreen.tsx index a95de51..1139566 100644 --- a/src/screens/home/PostDetailScreen.tsx +++ b/src/screens/home/PostDetailScreen.tsx @@ -42,6 +42,7 @@ import { postService, commentService, authService, showPrompt, voteService } fro import { postSyncService } from '@/services/post'; import { useCursorPagination } from '../../hooks/useCursorPagination'; import { CommentItem, VoteCard, ReportDialog, ShareSheet, PostContentRenderer, PostMentionInput } from '../../components/business'; +import type { PostMentionInputHandle } from '../../components/business'; import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks'; import { @@ -1271,10 +1272,22 @@ export const PostDetailScreen: React.FC = () => { // 回复评论 const [replyingTo, setReplyingTo] = useState(null); const [isComposerVisible, setIsComposerVisible] = useState(false); + const commentInputRef = useRef(null); + + // 展开编辑器后自动聚焦输入框、唤起键盘,提升回复体验 + const focusCommentInput = useCallback(() => { + // 展开态需要先渲染,延迟一帧再 focus,避免 ref 尚未挂载到真实 TextInput + requestAnimationFrame(() => { + setTimeout(() => { + commentInputRef.current?.focus(); + }, 60); + }); + }, []); const handleReply = (comment: Comment) => { setReplyingTo(comment); setIsComposerVisible(true); + focusCommentInput(); }; const handleCancelReply = () => { @@ -1363,6 +1376,7 @@ export const PostDetailScreen: React.FC = () => { const openComposer = () => { setIsComposerVisible(true); + focusCommentInput(); }; const closeComposer = () => { @@ -1471,6 +1485,7 @@ export const PostDetailScreen: React.FC = () => { {/* Text Input —— 小红书风格:无边框、自动扩展 */} ; }; +/** + * 根据 MIME 类型返回文件图标名与主题色 + */ +function getFileVisual(mime?: string): { icon: string; color: string } { + if (!mime) return { icon: 'file-document-outline', color: '#5C6BC0' }; + const m = mime.toLowerCase(); + if (m === 'application/pdf') return { icon: 'file-pdf-box', color: '#E53935' }; + if (m.includes('word') || m === 'application/msword') return { icon: 'file-word-box', color: '#1E88E5' }; + if (m.includes('excel') || m === 'application/vnd.ms-excel' || m === 'text/csv') return { icon: 'file-excel-box', color: '#43A047' }; + if (m.includes('powerpoint') || m === 'application/vnd.ms-powerpoint') return { icon: 'file-powerpoint-box', color: '#FB8C00' }; + if (m === 'application/zip' || m.includes('compressed') || m.includes('rar') || m.includes('7z') || m.includes('gzip') || m.includes('tar')) { + return { icon: 'folder-zip-outline', color: '#8D6E63' }; + } + if (m.startsWith('audio/')) return { icon: 'file-music-outline', color: '#8E24AA' }; + if (m.startsWith('video/')) return { icon: 'file-video-outline', color: '#00897B' }; + if (m.startsWith('image/')) return { icon: 'file-image-outline', color: '#FF6B35' }; + if (m === 'application/json' || m === 'text/xml' || m === 'application/xml') return { icon: 'code-json', color: '#455A64' }; + if (m === 'text/plain' || m === 'text/markdown') return { icon: 'file-document-outline', color: '#607D8B' }; + return { icon: 'file-document-outline', color: '#5C6BC0' }; +} + +/** + * 打开/下载文件:移动端用浏览器在新页面打开 URL(系统会提示下载/预览), + * 此处不再依赖 expo-file-system(SDK 56 已废弃 downloadAsync)。 + */ +async function openRemoteFile(url: string, name: string) { + if (!url) return; + try { + await Linking.openURL(url); + } catch (error) { + console.warn('打开文件失败:', error); + } +} + /** * 渲染文件 Segment + * 当 data.expired 为 true 时显示"文件已过期"失效态,禁用点击。 */ const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ data, isMe }) => { const styles = useSegmentStyles(); const themeColors = useAppColors(); + const [downloading, setDownloading] = useState(false); const fileSize = data.size ? formatFileSize(data.size) : ''; + const visual = getFileVisual(data.mime_type); + const isExpired = !!data.expired; + + const handlePress = async () => { + if (isExpired || !data.url || downloading) return; + setDownloading(true); + try { + await openRemoteFile(data.url, data.name || 'file'); + } finally { + setDownloading(false); + } + }; + + // 失效态:灰态卡片 + 时钟图标 + "文件已过期",禁用点击 + if (isExpired) { + return ( + + + + + + + {data.name} + + + 文件已过期 + + + + ); + } + return ( - + @@ -502,8 +581,15 @@ const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ d > {data.name} - {fileSize ? {fileSize} : null} + + {downloading ? '下载中…' : (fileSize || (data.mime_type || '文件'))} + + ); }; @@ -1021,6 +1107,14 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) { fontWeight: '500', }, + // 文件过期失效态 + fileExpired: { + opacity: 0.65, + }, + fileExpiredText: { + color: colors.chat.textTertiary, + }, + // 链接 - QQ风格:卡片式设计 linkContainer: { borderRadius: 16, diff --git a/src/screens/message/components/ChatScreen/constants.ts b/src/screens/message/components/ChatScreen/constants.ts index 74de4d7..00ec5bc 100644 --- a/src/screens/message/components/ChatScreen/constants.ts +++ b/src/screens/message/components/ChatScreen/constants.ts @@ -171,20 +171,13 @@ export const MORE_ACTIONS: MoreAction[] = [ color: '#26A69A', gradientColors: ['#4DB6AC', '#00897B'], }, - { - id: 'file', - icon: 'file-document', - name: '文件', + { + id: 'file', + icon: 'file-document', + name: '文件', color: '#5C6BC0', gradientColors: ['#7986CB', '#3F51B5'], }, - { - id: 'location', - icon: 'map-marker', - name: '位置', - color: '#EC407A', - gradientColors: ['#F06292', '#D81B60'], - }, ]; // 消息撤回时间限制(毫秒) diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index f64ab6a..999d0ea 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -20,7 +20,9 @@ import { import { useLocalSearchParams, router } from 'expo-router'; import { formatChatTime } from '@/utils/formatTime'; import * as ImagePicker from 'expo-image-picker'; -import { GroupMemberResponse, MessageSegment, TextSegmentData, ImageSegmentData, AtSegmentData, ReplySegmentData, MessageStatus } from '../../../../types/dto'; +import * as DocumentPicker from 'expo-document-picker'; +import { Linking } from 'react-native'; +import { GroupMemberResponse, MessageSegment, TextSegmentData, ImageSegmentData, FileSegmentData, AtSegmentData, ReplySegmentData, MessageStatus } from '../../../../types/dto'; import { messageService } from '@/services/message'; import { uploadService } from '@/services/upload'; import { ApiError } from '@/services/core'; @@ -1049,6 +1051,38 @@ export const useChatScreen = (props?: ChatScreenProps) => { return segments; }, []); + /** + * 构建文件消息的 segments 数组 + */ + const buildFileSegments = useCallback(( + fileUrl: string, + name: string, + size?: number, + mimeType?: string, + replyToMessage?: GroupMessage | null, + ): MessageSegment[] => { + const segments: MessageSegment[] = []; + + if (replyToMessage) { + segments.push({ + type: 'reply', + data: { id: replyToMessage.id, seq: replyToMessage.seq } as ReplySegmentData + }); + } + + segments.push({ + type: 'file', + data: { + url: fileUrl, + name, + size, + mime_type: mimeType, + } as FileSegmentData + }); + + return segments; + }, []); + // 【新架构】发送消息(支持纯文字、纯多图、图文同条) const handleSend = useCallback(async () => { const trimmedText = inputText.trim(); @@ -1253,6 +1287,89 @@ export const useChatScreen = (props?: ChatScreenProps) => { setActivePanel('none'); }, [pendingAttachments.length]); + // 选择并发送文件(单选,立即上传后发送) + const handlePickFile = useCallback(async () => { + if (!conversationId) { + setActivePanel('none'); + return; + } + + if (isGroupChat && isMuted) { + Alert.alert('无法发送', muteAll ? '当前群组已开启全员禁言' : '你已被管理员禁言'); + setActivePanel('none'); + return; + } + + setActivePanel('none'); + + try { + const result = await DocumentPicker.getDocumentAsync({ + multiple: false, + copyToCacheDirectory: true, + }); + if (result.canceled || !result.assets || result.assets.length === 0) { + return; + } + + const asset = result.assets[0]; + setUploadingAttachments(true); + + const uploaded = await uploadService.uploadFile( + { + uri: asset.uri, + name: asset.name, + type: asset.mimeType || undefined, + }, + 'chat' + ); + + if (!uploaded?.url) { + Alert.alert('上传失败', getSendErrorMessage(null, '文件上传失败,请重试')); + return; + } + + const segments = buildFileSegments( + uploaded.url, + uploaded.name || asset.name, + uploaded.size ?? asset.size, + uploaded.mime_type ?? asset.mimeType, + replyingTo, + ); + + setSending(true); + try { + if (isGroupChat && effectiveGroupId) { + await messageService.sendMessageByAction('group', conversationId, segments); + } else { + await sendMessageViaManager(segments); + } + setReplyingTo(null); + setTimeout(() => scrollToLatest(false, false, 'send-file'), 100); + } catch (error) { + console.error('发送文件消息失败:', error); + Alert.alert('发送失败', getSendErrorMessage(error, '消息发送失败,请重试')); + } finally { + setSending(false); + } + } catch (error) { + console.error('选择文件失败:', error); + Alert.alert('错误', getSendErrorMessage(error, '选择文件失败')); + } finally { + setUploadingAttachments(false); + } + }, [ + conversationId, + isGroupChat, + isMuted, + muteAll, + effectiveGroupId, + replyingTo, + buildFileSegments, + sendMessageViaManager, + scrollToLatest, + getSendErrorMessage, + ]); + // 处理更多功能 const handleMoreAction = useCallback((actionId: string) => { switch (actionId) { @@ -1281,17 +1398,12 @@ export const useChatScreen = (props?: ChatScreenProps) => { handleTakePhoto(); break; case 'file': - Alert.alert('提示', '文件功能即将上线'); - setActivePanel('none'); - break; - case 'location': - Alert.alert('提示', '位置功能即将上线'); - setActivePanel('none'); + handlePickFile(); break; default: setActivePanel('none'); } - }, [handlePickImage, handleTakePhoto, isGroupChat, otherUser, conversationId]); + }, [handlePickImage, handleTakePhoto, handlePickFile, isGroupChat, otherUser, conversationId]); // 插入表情 const handleInsertEmoji = useCallback((emoji: string) => { diff --git a/src/screens/profile/AccountDeletionScreen.tsx b/src/screens/profile/AccountDeletionScreen.tsx index ba6bd8e..5e556ba 100644 --- a/src/screens/profile/AccountDeletionScreen.tsx +++ b/src/screens/profile/AccountDeletionScreen.tsx @@ -1,8 +1,10 @@ import { StatusBar } from 'expo-status-bar'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Alert, + KeyboardAvoidingView, + Platform, ScrollView, StyleSheet, TextInput, @@ -13,7 +15,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { authService } from '../../services'; -import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme'; +import { spacing, fontSizes, useAppColors, type AppColors } from '../../theme'; import { Text, SimpleHeader } from '../../components/common'; import { useResponsive, useResponsiveSpacing } from '../../hooks'; import { useAuthStore } from '../../stores'; @@ -33,6 +35,9 @@ function createAccountDeletionStyles(colors: AppColors) { alignItems: 'center', justifyContent: 'center', }, + keyboardView: { + flex: 1, + }, scrollContent: { paddingVertical: spacing.lg, }, @@ -41,53 +46,153 @@ function createAccountDeletionStyles(colors: AppColors) { alignSelf: 'center', width: '100%', }, - section: { - marginBottom: spacing['2xl'], + + // 顶部叙述区:左对齐、有呼吸感 + heroSection: { + paddingHorizontal: spacing['2xl'], + paddingTop: spacing.md, + paddingBottom: spacing.xl, }, - // 警告卡片 - 扁平化风格 - warningCard: { - backgroundColor: colors.error.light + '20', - borderRadius: 14, - padding: spacing.lg, - marginHorizontal: spacing['2xl'], - marginBottom: spacing.xl, - }, - warningTitle: { - fontWeight: '700', - fontSize: fontSizes.md, + heroEyebrow: { + fontSize: 13, + color: colors.text.hint, + letterSpacing: 1, marginBottom: spacing.sm, - color: colors.error.main, }, - warningText: { - color: colors.error.dark, - marginBottom: spacing.xs, - fontSize: fontSizes.sm, - }, - // 状态卡片 - 扁平化风格 - statusCard: { - backgroundColor: colors.warning.light + '30', - borderRadius: 14, - padding: spacing.lg, - marginHorizontal: spacing['2xl'], - marginBottom: spacing.xl, - }, - statusTitle: { + heroTitle: { + fontSize: 24, fontWeight: '700', - fontSize: fontSizes.md, + color: colors.text.primary, + lineHeight: 32, marginBottom: spacing.sm, - color: colors.warning.dark, }, - daysText: { + heroDesc: { + fontSize: 15, + color: colors.text.secondary, + lineHeight: 22, + }, + + // 倒计时区:圆环 + 数字 + 文案,替代"大数字 + 标签"卡片 + countdownSection: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing['2xl'], + paddingVertical: spacing.lg, + }, + countdownRing: { + width: 88, + height: 88, + borderRadius: 44, + borderWidth: 4, + borderColor: colors.warning.main, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.lg, + }, + countdownNumber: { fontSize: 32, fontWeight: '700', color: colors.warning.dark, - textAlign: 'center', - marginVertical: spacing.md, + lineHeight: 36, }, - // 内容区域 - sectionContent: { - marginHorizontal: spacing['2xl'], - marginBottom: spacing.xl, + countdownUnit: { + fontSize: 11, + color: colors.text.secondary, + marginTop: -2, + }, + countdownTextWrap: { + flex: 1, + }, + countdownTitle: { + fontSize: 16, + fontWeight: '600', + color: colors.text.primary, + marginBottom: 4, + }, + countdownDesc: { + fontSize: 13, + color: colors.text.secondary, + lineHeight: 19, + }, + + // 引导文 + 列表(无背景、无圆角,自然排版) + guideSection: { + paddingHorizontal: spacing['2xl'], + paddingTop: spacing.lg, + }, + guideLabel: { + fontSize: 13, + color: colors.text.secondary, + fontWeight: '500', + marginBottom: spacing.md, + }, + guideItem: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingVertical: 10, + }, + guideIcon: { + marginRight: spacing.md, + marginTop: 2, + }, + guideTextWrap: { + flex: 1, + }, + guideTitle: { + fontSize: 15, + fontWeight: '500', + color: colors.text.primary, + marginBottom: 2, + }, + guideDesc: { + fontSize: 13, + color: colors.text.secondary, + lineHeight: 19, + }, + guideDivider: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.divider, + marginLeft: spacing['2xl'] + 22 + spacing.md, + }, + + // 注意事项(无填色,仅左侧色条) + noticeSection: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingHorizontal: spacing['2xl'], + paddingTop: spacing.lg, + paddingBottom: spacing.md, + }, + noticeBar: { + width: 3, + alignSelf: 'stretch', + backgroundColor: colors.error.main, + borderRadius: 2, + marginRight: spacing.md, + }, + noticeTextWrap: { + flex: 1, + }, + noticeTitle: { + fontSize: 14, + fontWeight: '600', + color: colors.error.main, + marginBottom: 4, + }, + noticeDesc: { + fontSize: 13, + color: colors.text.secondary, + lineHeight: 20, + }, + + // 表单区(与 AccountSecurity 风格一致:分节标题 + 输入框) + formSection: { + marginTop: spacing.lg, + }, + sectionHeader: { + paddingHorizontal: spacing['2xl'], + marginBottom: spacing.sm, + marginTop: spacing.sm, }, sectionTitle: { fontWeight: '600', @@ -95,47 +200,40 @@ function createAccountDeletionStyles(colors: AppColors) { color: colors.text.secondary, textTransform: 'uppercase', letterSpacing: 0.5, - marginBottom: spacing.md, }, - listItem: { + + // 输入框 + inputWrapper: { flexDirection: 'row', - alignItems: 'flex-start', - marginBottom: spacing.sm, - }, - listBullet: { - marginRight: spacing.sm, - marginTop: 2, - }, - // 输入框 - 扁平化风格 - input: { + alignItems: 'center', backgroundColor: colors.background.default, borderRadius: 14, - padding: spacing.md, - fontSize: 16, + paddingHorizontal: spacing.lg, + height: 56, + marginHorizontal: spacing['2xl'], + marginBottom: spacing.md, + }, + inputIcon: { + marginRight: spacing.sm, + }, + input: { + flex: 1, color: colors.text.primary, + fontSize: fontSizes.md, height: 56, }, - // 按钮行 + eyeButton: { + padding: 4, + marginLeft: 4, + }, + + // 按钮行:次按钮在左、危险按钮在右,与全站保持一致 buttonRow: { flexDirection: 'row', gap: spacing.md, marginTop: spacing.lg, marginHorizontal: spacing['2xl'], }, - // 扁平化按钮 - primaryButton: { - flex: 1, - height: 56, - borderRadius: 14, - backgroundColor: colors.primary.main, - alignItems: 'center', - justifyContent: 'center', - }, - primaryButtonText: { - color: colors.text.inverse, - fontSize: fontSizes.md, - fontWeight: '600', - }, secondaryButton: { flex: 1, height: 56, @@ -165,11 +263,38 @@ function createAccountDeletionStyles(colors: AppColors) { fontWeight: '600', }, buttonDisabled: { - opacity: 0.6, + opacity: 0.5, }, - cancelButton: { - marginTop: spacing.lg, + primaryButton: { + height: 56, + borderRadius: 14, + backgroundColor: colors.primary.main, + alignItems: 'center', + justifyContent: 'center', marginHorizontal: spacing['2xl'], + marginTop: spacing.md, + }, + primaryButtonText: { + color: colors.text.inverse, + fontSize: fontSizes.md, + fontWeight: '600', + }, + + // 页脚:联系客服 + footer: { + alignItems: 'center', + marginTop: spacing.xl, + paddingHorizontal: spacing['2xl'], + }, + footerText: { + fontSize: 13, + color: colors.text.hint, + lineHeight: 20, + textAlign: 'center', + }, + footerLink: { + color: colors.primary.main, + fontWeight: '500', }, }); } @@ -185,9 +310,9 @@ export const AccountDeletionScreen: React.FC = () => { const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; - const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); const loadStatus = useCallback(async () => { @@ -284,121 +409,244 @@ export const AccountDeletionScreen: React.FC = () => { ); } + // 待注销状态 + if (status?.is_pending_deletion) { + return ( + + + router.back()} /> + + + {/* 顶部叙述 */} + + ACCOUNT · 注销申请中 + 我们将在倒计时结束后清除你的账号 + + 在此期间,你随时可以撤销申请,账号会立即恢复正常使用。 + + + + {/* 倒计时 */} + + + {status.cool_down_days || 90} + + + + 距离永久删除 + + 再次登录或点击下方按钮可立即取消注销。 + + + + + {/* 取消按钮 */} + + {submitting ? ( + + ) : ( + 撤销注销申请 + )} + + + {/* 页脚 */} + + + 如有疑虑可联系 客服 获取帮助 + + + + + + ); + } + + // 正常状态:申请注销 return ( router.back()} /> - - - {status?.is_pending_deletion ? ( - - - - 账号注销申请中 - - - 您的账号将在以下天数后永久删除: - - - {status.cool_down_days || 90} 天 - - - 在此期间,您可以通过重新登录或点击下方按钮来取消注销申请。 - - - {submitting ? ( - - ) : ( - 取消注销申请 - )} - + + + + {/* 顶部叙述 */} + + ACCOUNT · 注销 + 在离开之前,我们想让你知道这些 + + 注销并非立即生效,提交后你有 90 天的冷静期,反悔了随时可以回来。 + + + + {/* 注销影响 - 列表式(无背景) */} + + 注销后会发生什么 + + + + + 个人资料会被清除 + 头像、昵称、简介等所有个人信息都将被永久删除。 + + + + + + + + 历史内容会保留 + 你发布的帖子、评论将保留,但作者会显示为「已注销用户」。 + + + + + + + + 关注关系被解绑 + 你关注的人、粉丝、收藏、点赞等社交关系会被一并清除。 + + + + + + + + 90 天冷静期 + 期间重新登录即可撤销申请,账号会立刻恢复。 + - ) : ( - - {/* 警告卡片 */} - - - 警告:账号注销后将无法恢复 + + {/* 红色提示 - 左侧细线代替大色块 */} + + + + 这是不可恢复的操作 + + 90 天后所有数据将被永久删除,届时无法通过任何方式找回。请确认你已备份好需要保留的内容。 - - 注销后,您的账号将在90天后永久删除。 - - - 在此期间,您可以通过重新登录来取消注销。 + + + + {/* 密码确认 */} + + + + 身份验证 - {/* 注销说明 */} - - 注销后将发生什么 - - - - 您的个人资料将被删除 - - - - - - 您发布的帖子、评论将保留,但显示为「已注销用户」 - - - - - - 您的关注、粉丝关系将被清除 - - - - - - 您的收藏、点赞记录将被删除 - - - - - {/* 密码确认 */} - - 请输入密码确认 + + - - - {/* 按钮行 */} - router.back()} + onPress={() => setShowPassword(!showPassword)} + style={styles.eyeButton} > - 返回 - - - {submitting ? ( - - ) : ( - 确认注销 - )} + - )} - - + + {/* 按钮行 */} + + router.back()} + activeOpacity={0.8} + > + 再想想 + + + {submitting ? ( + + ) : ( + 确认申请注销 + )} + + + + {/* 页脚 */} + + + 遇到问题?可以联系 客服 + 我们会帮你处理 + + + + + ); }; diff --git a/src/screens/profile/NotificationSettingsScreen.tsx b/src/screens/profile/NotificationSettingsScreen.tsx index de9431c..314d846 100644 --- a/src/screens/profile/NotificationSettingsScreen.tsx +++ b/src/screens/profile/NotificationSettingsScreen.tsx @@ -175,37 +175,6 @@ export const NotificationSettingsScreen: React.FC = () => { } }; - const handleAutoStartModeChange = async (mode: AutoStartMode) => { - if (mode === AutoStartMode.BACKGROUND) { - Alert.alert( - '后台消息接收', - getAutoStartDescription() + '\n\n开启后,应用可在后台接收消息推送。', - [ - { text: '取消', style: 'cancel' }, - { - text: '同意并开启', - onPress: async () => { - await setAutoStartMode(AutoStartMode.BACKGROUND); - setAutoStartModeState(AutoStartMode.BACKGROUND); - setAutoStartConsented(true); - // 重新初始化后台服务以应用新设置 - const { reinitBackgroundService } = await import('@/services/background'); - await reinitBackgroundService(); - Alert.alert('已开启', '后台消息接收已开启。您可随时在设置中关闭。'); - }, - }, - ] - ); - } else { - await setAutoStartMode(AutoStartMode.SILENT); - setAutoStartModeState(AutoStartMode.SILENT); - setAutoStartConsented(false); - // 重新初始化后台服务以应用新设置 - const { reinitBackgroundService } = await import('@/services/background'); - await reinitBackgroundService(); - } - }; - const syncModeOptions: { mode: BackgroundSyncMode; title: string; subtitle: string; icon: string }[] = [ { mode: BackgroundSyncMode.DISABLED, diff --git a/src/services/platform/apkUpdateService.ts b/src/services/platform/apkUpdateService.ts index 0860d19..5cb59f3 100644 --- a/src/services/platform/apkUpdateService.ts +++ b/src/services/platform/apkUpdateService.ts @@ -39,18 +39,28 @@ export interface VersionCheckResult { hasUpdate: boolean; currentVersion: string; latestVersion: string; + runtimeVersion: string; downloadUrl: string; size: number; } /** - * 比较版本号 + * 比较语义版本号(SemVer 三段数字) * 返回: 1 表示 v1 > v2, -1 表示 v1 < v2, 0 表示相等 + * + * 注意:仅做 x.y.z 形式的纯数字比较;任何一段非数字(NaN)会被当作 0 处理, + * 避免运行时版本号(commit count 等)混入时导致方向相反的误判。 */ function compareVersions(v1: string, v2: string): number { - const parts1 = v1.split('.').map(Number); - const parts2 = v2.split('.').map(Number); - + const parts1 = v1.split('.').map((s) => { + const n = Number.parseInt(s, 10); + return Number.isFinite(n) ? n : 0; + }); + const parts2 = v2.split('.').map((s) => { + const n = Number.parseInt(s, 10); + return Number.isFinite(n) ? n : 0; + }); + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { const p1 = parts1[i] || 0; const p2 = parts2[i] || 0; @@ -86,10 +96,10 @@ async function fetchLatestAPKVersion(): Promise { } /** - * 获取当前应用版本 + * 获取当前应用语义版本(来自 app.json / app.config.js 的 expo.version) */ function getCurrentVersion(): string { - return Constants.expoConfig?.version || '1.0.0'; + return Constants.expoConfig?.version || '1.0.1'; } /** @@ -144,6 +154,7 @@ async function downloadAndInstallAPK(downloadUrl: string, version: string): Prom } try { + // 文件名带语义版本(用户可读);runtimeVersion 已在 downloadUrl 中体现 const downloadPath = `${FileSystem.documentDirectory}with-you-${version}.apk`; // 显示下载进度 @@ -247,15 +258,18 @@ export async function checkForAPKUpdate(force: boolean = false): Promise { try { - const response = await api.upload('/upload/file', { + const response = await api.upload('/uploads/files', { uri: file.uri, name: file.name || `file_${Date.now()}`, type: file.type || 'application/octet-stream', }, { folder }); - + return response.data; } catch (error) { console.error('上传文件失败:', error); diff --git a/src/types/dto/message.ts b/src/types/dto/message.ts index 368213e..2e3d443 100644 --- a/src/types/dto/message.ts +++ b/src/types/dto/message.ts @@ -46,6 +46,8 @@ export interface FileSegmentData { name: string; size?: number; mime_type?: string; + /** 文件已过期(已被服务端清理),前端据此显示失效状态 */ + expired?: boolean; } export interface AtSegmentData {