feat(push): integrate Honor and Xiaomi push plugins with related updates
This commit is contained in:
@@ -48,7 +48,7 @@ jobs:
|
|||||||
- name: Resolve runtime version
|
- name: Resolve runtime version
|
||||||
id: runtime
|
id: runtime
|
||||||
run: |
|
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 "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ jobs:
|
|||||||
- name: Resolve runtime version
|
- name: Resolve runtime version
|
||||||
id: runtime
|
id: runtime
|
||||||
run: |
|
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 "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,11 @@ FROM node:25-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
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 ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
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
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,21 @@ const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1';
|
|||||||
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
|
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
|
||||||
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
|
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
|
||||||
|
|
||||||
function getGitShortHash() {
|
function getCommitCount() {
|
||||||
try {
|
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 {
|
} 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) {
|
function toManifestUrl(baseUrl, portOverride) {
|
||||||
const parsed = new URL(baseUrl);
|
const parsed = new URL(baseUrl);
|
||||||
@@ -60,9 +66,9 @@ const filteredPlugins = isWeb
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
...expo,
|
...expo,
|
||||||
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
||||||
runtimeVersion: {
|
// runtimeVersion 用 build number(commit count + 偏移):单调递增、纯数字、与 version (语义版本) 解耦
|
||||||
policy: 'appVersion',
|
// 字符串形式,等价于 policy: 'custom'
|
||||||
},
|
runtimeVersion: buildNumber,
|
||||||
updates: {
|
updates: {
|
||||||
...(expo.updates || {}),
|
...(expo.updates || {}),
|
||||||
url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||||
@@ -71,12 +77,12 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
ios: {
|
ios: {
|
||||||
...expo.ios,
|
...expo.ios,
|
||||||
buildNumber: gitBuildSuffix,
|
buildNumber: buildNumber,
|
||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
...expo.android,
|
...expo.android,
|
||||||
package: 'cn.qczlit.withyou',
|
package: 'cn.qczlit.withyou',
|
||||||
versionCode: parseInt(gitBuildSuffix, 16),
|
versionCode: parseInt(buildNumber, 10),
|
||||||
},
|
},
|
||||||
// Web 端使用过滤后的插件
|
// Web 端使用过滤后的插件
|
||||||
plugins: filteredPlugins,
|
plugins: filteredPlugins,
|
||||||
|
|||||||
8
app.json
8
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "威友",
|
"name": "威友",
|
||||||
"slug": "qojo",
|
"slug": "qojo",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
},
|
},
|
||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
"package": "cn.qczlit.withyou",
|
"package": "cn.qczlit.withyou",
|
||||||
"versionCode": 7,
|
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"VIBRATE",
|
"VIBRATE",
|
||||||
"RECORD_AUDIO",
|
"RECORD_AUDIO",
|
||||||
@@ -53,7 +52,6 @@
|
|||||||
"android.permission.READ_EXTERNAL_STORAGE",
|
"android.permission.READ_EXTERNAL_STORAGE",
|
||||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||||
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
||||||
"android.permission.ACCESS_MEDIA_LOCATION",
|
|
||||||
"android.permission.READ_MEDIA_IMAGES",
|
"android.permission.READ_MEDIA_IMAGES",
|
||||||
"android.permission.READ_MEDIA_VIDEO",
|
"android.permission.READ_MEDIA_VIDEO",
|
||||||
"android.permission.READ_MEDIA_AUDIO",
|
"android.permission.READ_MEDIA_AUDIO",
|
||||||
@@ -108,7 +106,7 @@
|
|||||||
{
|
{
|
||||||
"photosPermission": "允许威友访问您的照片以发布内容",
|
"photosPermission": "允许威友访问您的照片以发布内容",
|
||||||
"savePhotosPermission": "允许威友保存照片到您的相册",
|
"savePhotosPermission": "允许威友保存照片到您的相册",
|
||||||
"isAccessMediaLocationEnabled": true
|
"isAccessMediaLocationEnabled": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -157,6 +155,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"./plugins/withHuaweiPush",
|
"./plugins/withHuaweiPush",
|
||||||
|
"./plugins/withXiaomiPush",
|
||||||
|
"./plugins/withHonorPush",
|
||||||
"expo-callkit-telecom",
|
"expo-callkit-telecom",
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
|
|||||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "with_you",
|
"name": "with_you",
|
||||||
"version": "0.0.2",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "with_you",
|
"name": "with_you",
|
||||||
"version": "0.0.2",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/ui": "~56.0.17",
|
"@expo/ui": "~56.0.17",
|
||||||
"@expo/vector-icons": "^15.1.1",
|
"@expo/vector-icons": "^15.1.1",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
|
"expo-document-picker": "~56.0.4",
|
||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
"expo-font": "~56.0.6",
|
"expo-font": "~56.0.6",
|
||||||
"expo-haptics": "~56.0.3",
|
"expo-haptics": "~56.0.3",
|
||||||
@@ -5530,6 +5531,15 @@
|
|||||||
"expo": "*"
|
"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": {
|
"node_modules/expo-eas-client": {
|
||||||
"version": "56.0.1",
|
"version": "56.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/expo-eas-client/-/expo-eas-client-56.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/expo-eas-client/-/expo-eas-client-56.0.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "with_you",
|
"name": "with_you",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
"expo-camera": "~56.0.8",
|
"expo-camera": "~56.0.8",
|
||||||
"expo-constants": "~56.0.18",
|
"expo-constants": "~56.0.18",
|
||||||
"expo-dev-client": "~56.0.20",
|
"expo-dev-client": "~56.0.20",
|
||||||
|
"expo-document-picker": "~56.0.4",
|
||||||
"expo-file-system": "~56.0.8",
|
"expo-file-system": "~56.0.8",
|
||||||
"expo-font": "~56.0.6",
|
"expo-font": "~56.0.6",
|
||||||
"expo-haptics": "~56.0.3",
|
"expo-haptics": "~56.0.3",
|
||||||
|
|||||||
205
plugins/withHonorPush.js
Normal file
205
plugins/withHonorPush.js
Normal file
@@ -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;
|
||||||
133
plugins/withXiaomiPush.js
Normal file
133
plugins/withXiaomiPush.js
Normal file
@@ -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;
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* 检测输入中的 @ 字符,弹出关注者列表供选择
|
* 检测输入中的 @ 字符,弹出关注者列表供选择
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -51,7 +51,12 @@ interface PostMentionInputProps {
|
|||||||
returnKeyType?: 'default' | 'send' | 'done';
|
returnKeyType?: 'default' | 'send' | 'done';
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
export interface PostMentionInputHandle {
|
||||||
|
focus: () => void;
|
||||||
|
blur: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PostMentionInput = forwardRef<PostMentionInputHandle, PostMentionInputProps>(({
|
||||||
value,
|
value,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
onSegmentsChange,
|
onSegmentsChange,
|
||||||
@@ -63,7 +68,7 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|||||||
onPostRefPasted,
|
onPostRefPasted,
|
||||||
onSubmitEditing,
|
onSubmitEditing,
|
||||||
returnKeyType = 'default',
|
returnKeyType = 'default',
|
||||||
}) => {
|
}, ref) => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const currentUser = useAuthStore(s => s.currentUser);
|
const currentUser = useAuthStore(s => s.currentUser);
|
||||||
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
|
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
|
||||||
@@ -75,6 +80,21 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|||||||
const [postSearchTimer, setPostSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
const [postSearchTimer, setPostSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
// 暴露 focus / blur 方法给父组件,用于在模式切换(如点击回复)后自动唤起键盘
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
focus: () => {
|
||||||
|
// 展开动画/挂载需要时间,延迟一帧后再聚焦,避免在折叠态未真正展开时调用无效
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
blur: () => {
|
||||||
|
inputRef.current?.blur();
|
||||||
|
},
|
||||||
|
}), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loaded && currentUser?.id) {
|
if (!loaded && currentUser?.id) {
|
||||||
loadFollowing();
|
loadFollowing();
|
||||||
@@ -361,7 +381,7 @@ const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export { default as VoteEditor } from './VoteEditor';
|
|||||||
export { default as VotePreview } from './VotePreview';
|
export { default as VotePreview } from './VotePreview';
|
||||||
export { default as PostContentRenderer } from './PostContentRenderer';
|
export { default as PostContentRenderer } from './PostContentRenderer';
|
||||||
export { default as PostMentionInput } from './PostMentionInput';
|
export { default as PostMentionInput } from './PostMentionInput';
|
||||||
|
export type { PostMentionInputHandle } from './PostMentionInput';
|
||||||
export { default as BlockEditor } from './BlockEditor';
|
export { default as BlockEditor } from './BlockEditor';
|
||||||
export type { BlockEditorHandle } from './BlockEditor';
|
export type { BlockEditorHandle } from './BlockEditor';
|
||||||
export { default as ReportDialog } from './ReportDialog';
|
export { default as ReportDialog } from './ReportDialog';
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react';
|
|||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
import { jpushService } from '../services/notification/jpushService';
|
import { jpushService } from '../services/notification/jpushService';
|
||||||
import { isAutoStartAllowed } from '../services/consent/autoStartConsent';
|
|
||||||
|
|
||||||
export function useRegisterPushDevice(isAuthenticated: boolean, userID?: string) {
|
export function useRegisterPushDevice(isAuthenticated: boolean, userID?: string) {
|
||||||
const deviceRegistered = useRef(false);
|
const deviceRegistered = useRef(false);
|
||||||
@@ -10,12 +9,12 @@ export function useRegisterPushDevice(isAuthenticated: boolean, userID?: string)
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === 'web' || !isAuthenticated || !userID) return;
|
if (Platform.OS === 'web' || !isAuthenticated || !userID) return;
|
||||||
|
|
||||||
// 仅在用户同意自启动后才初始化 JPush
|
// JPush 初始化只取决于登录态——这是基础推送通道(获取 RegistrationID、
|
||||||
if (!isAutoStartAllowed()) {
|
// 向服务器注册设备 token),与"是否允许后台自启动/保活"无关。
|
||||||
console.log('[useRegisterPushDevice] 用户未同意自启动,跳过 JPush 初始化');
|
// 后台保活由 backgroundService 控制;退后台是否保持 JPush 长连接由
|
||||||
return;
|
// jpushService._setBackgroundKeepLongConn() 根据 autoStart 同意状态自行处理。
|
||||||
}
|
// 若在此处用 consent 拦截 init,未同意用户将永远拿不到 RegistrationID,
|
||||||
|
// 即便在前台/已授予通知权限也无法收到推送。
|
||||||
if (!deviceRegistered.current) {
|
if (!deviceRegistered.current) {
|
||||||
deviceRegistered.current = true;
|
deviceRegistered.current = true;
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { postService, commentService, authService, showPrompt, voteService } fro
|
|||||||
import { postSyncService } from '@/services/post';
|
import { postSyncService } from '@/services/post';
|
||||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||||
import { CommentItem, VoteCard, ReportDialog, ShareSheet, PostContentRenderer, PostMentionInput } from '../../components/business';
|
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 { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
|
||||||
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks';
|
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks';
|
||||||
import {
|
import {
|
||||||
@@ -1271,10 +1272,22 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
// 回复评论
|
// 回复评论
|
||||||
const [replyingTo, setReplyingTo] = useState<Comment | null>(null);
|
const [replyingTo, setReplyingTo] = useState<Comment | null>(null);
|
||||||
const [isComposerVisible, setIsComposerVisible] = useState(false);
|
const [isComposerVisible, setIsComposerVisible] = useState(false);
|
||||||
|
const commentInputRef = useRef<PostMentionInputHandle>(null);
|
||||||
|
|
||||||
|
// 展开编辑器后自动聚焦输入框、唤起键盘,提升回复体验
|
||||||
|
const focusCommentInput = useCallback(() => {
|
||||||
|
// 展开态需要先渲染,延迟一帧再 focus,避免 ref 尚未挂载到真实 TextInput
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
commentInputRef.current?.focus();
|
||||||
|
}, 60);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleReply = (comment: Comment) => {
|
const handleReply = (comment: Comment) => {
|
||||||
setReplyingTo(comment);
|
setReplyingTo(comment);
|
||||||
setIsComposerVisible(true);
|
setIsComposerVisible(true);
|
||||||
|
focusCommentInput();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelReply = () => {
|
const handleCancelReply = () => {
|
||||||
@@ -1363,6 +1376,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
|
|
||||||
const openComposer = () => {
|
const openComposer = () => {
|
||||||
setIsComposerVisible(true);
|
setIsComposerVisible(true);
|
||||||
|
focusCommentInput();
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeComposer = () => {
|
const closeComposer = () => {
|
||||||
@@ -1471,6 +1485,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
|
|
||||||
{/* Text Input —— 小红书风格:无边框、自动扩展 */}
|
{/* Text Input —— 小红书风格:无边框、自动扩展 */}
|
||||||
<PostMentionInput
|
<PostMentionInput
|
||||||
|
ref={commentInputRef}
|
||||||
value={commentText}
|
value={commentText}
|
||||||
onChangeText={setCommentText}
|
onChangeText={setCommentText}
|
||||||
onSegmentsChange={setCommentSegments}
|
onSegmentsChange={setCommentSegments}
|
||||||
|
|||||||
@@ -476,23 +476,102 @@ const renderVideoSegment = (data: VideoSegmentData, isMe: boolean): React.ReactN
|
|||||||
return <VideoSegment key={`video-${data.url}`} data={data} isMe={isMe} />;
|
return <VideoSegment key={`video-${data.url}`} data={data} isMe={isMe} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 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
|
* 渲染文件 Segment
|
||||||
|
* 当 data.expired 为 true 时显示"文件已过期"失效态,禁用点击。
|
||||||
*/
|
*/
|
||||||
const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ data, isMe }) => {
|
const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ data, isMe }) => {
|
||||||
const styles = useSegmentStyles();
|
const styles = useSegmentStyles();
|
||||||
const themeColors = useAppColors();
|
const themeColors = useAppColors();
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
const fileSize = data.size ? formatFileSize(data.size) : '';
|
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 (
|
||||||
|
<View
|
||||||
|
style={[styles.fileContainer, styles.fileExpired, isMe ? styles.fileMe : styles.fileOther]}
|
||||||
|
pointerEvents="none"
|
||||||
|
>
|
||||||
|
<View style={[styles.fileIcon, { backgroundColor: 'rgba(150,150,150,0.15)' }]}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="clock-alert-outline"
|
||||||
|
size={26}
|
||||||
|
color={themeColors.chat.textTertiary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.fileInfo}>
|
||||||
|
<Text
|
||||||
|
style={[styles.fileName, styles.fileExpiredText]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{data.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.fileSize, styles.fileExpiredText]}>
|
||||||
|
文件已过期
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.fileContainer, isMe ? styles.fileMe : styles.fileOther]}
|
style={[styles.fileContainer, isMe ? styles.fileMe : styles.fileOther]}
|
||||||
|
onPress={handlePress}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={styles.fileIcon}>
|
<View style={[styles.fileIcon, { backgroundColor: `${visual.color}22` }]}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="file-document"
|
name={visual.icon as any}
|
||||||
size={28}
|
size={26}
|
||||||
color={isMe ? themeColors.primary.contrast : themeColors.primary.main}
|
color={isMe ? themeColors.primary.contrast : visual.color}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.fileInfo}>
|
<View style={styles.fileInfo}>
|
||||||
@@ -502,8 +581,15 @@ const FileSegmentBody: React.FC<{ data: FileSegmentData; isMe: boolean }> = ({ d
|
|||||||
>
|
>
|
||||||
{data.name}
|
{data.name}
|
||||||
</Text>
|
</Text>
|
||||||
{fileSize ? <Text style={styles.fileSize}>{fileSize}</Text> : null}
|
<Text style={styles.fileSize}>
|
||||||
|
{downloading ? '下载中…' : (fileSize || (data.mime_type || '文件'))}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={downloading ? 'progress-download' : 'download-outline'}
|
||||||
|
size={20}
|
||||||
|
color={isMe ? themeColors.primary.contrast : themeColors.chat.textTertiary}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1021,6 +1107,14 @@ function createSegmentStyles(colors: AppColors, fontSize: number = 16) {
|
|||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 文件过期失效态
|
||||||
|
fileExpired: {
|
||||||
|
opacity: 0.65,
|
||||||
|
},
|
||||||
|
fileExpiredText: {
|
||||||
|
color: colors.chat.textTertiary,
|
||||||
|
},
|
||||||
|
|
||||||
// 链接 - QQ风格:卡片式设计
|
// 链接 - QQ风格:卡片式设计
|
||||||
linkContainer: {
|
linkContainer: {
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
|
|||||||
@@ -171,20 +171,13 @@ export const MORE_ACTIONS: MoreAction[] = [
|
|||||||
color: '#26A69A',
|
color: '#26A69A',
|
||||||
gradientColors: ['#4DB6AC', '#00897B'],
|
gradientColors: ['#4DB6AC', '#00897B'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'file',
|
id: 'file',
|
||||||
icon: 'file-document',
|
icon: 'file-document',
|
||||||
name: '文件',
|
name: '文件',
|
||||||
color: '#5C6BC0',
|
color: '#5C6BC0',
|
||||||
gradientColors: ['#7986CB', '#3F51B5'],
|
gradientColors: ['#7986CB', '#3F51B5'],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'location',
|
|
||||||
icon: 'map-marker',
|
|
||||||
name: '位置',
|
|
||||||
color: '#EC407A',
|
|
||||||
gradientColors: ['#F06292', '#D81B60'],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 消息撤回时间限制(毫秒)
|
// 消息撤回时间限制(毫秒)
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import {
|
|||||||
import { useLocalSearchParams, router } from 'expo-router';
|
import { useLocalSearchParams, router } from 'expo-router';
|
||||||
import { formatChatTime } from '@/utils/formatTime';
|
import { formatChatTime } from '@/utils/formatTime';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
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 { messageService } from '@/services/message';
|
||||||
import { uploadService } from '@/services/upload';
|
import { uploadService } from '@/services/upload';
|
||||||
import { ApiError } from '@/services/core';
|
import { ApiError } from '@/services/core';
|
||||||
@@ -1049,6 +1051,38 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
return segments;
|
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 handleSend = useCallback(async () => {
|
||||||
const trimmedText = inputText.trim();
|
const trimmedText = inputText.trim();
|
||||||
@@ -1253,6 +1287,89 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
setActivePanel('none');
|
setActivePanel('none');
|
||||||
}, [pendingAttachments.length]);
|
}, [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) => {
|
const handleMoreAction = useCallback((actionId: string) => {
|
||||||
switch (actionId) {
|
switch (actionId) {
|
||||||
@@ -1281,17 +1398,12 @@ export const useChatScreen = (props?: ChatScreenProps) => {
|
|||||||
handleTakePhoto();
|
handleTakePhoto();
|
||||||
break;
|
break;
|
||||||
case 'file':
|
case 'file':
|
||||||
Alert.alert('提示', '文件功能即将上线');
|
handlePickFile();
|
||||||
setActivePanel('none');
|
|
||||||
break;
|
|
||||||
case 'location':
|
|
||||||
Alert.alert('提示', '位置功能即将上线');
|
|
||||||
setActivePanel('none');
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
setActivePanel('none');
|
setActivePanel('none');
|
||||||
}
|
}
|
||||||
}, [handlePickImage, handleTakePhoto, isGroupChat, otherUser, conversationId]);
|
}, [handlePickImage, handleTakePhoto, handlePickFile, isGroupChat, otherUser, conversationId]);
|
||||||
|
|
||||||
// 插入表情
|
// 插入表情
|
||||||
const handleInsertEmoji = useCallback((emoji: string) => {
|
const handleInsertEmoji = useCallback((emoji: string) => {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -13,7 +15,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
|||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { authService } from '../../services';
|
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 { Text, SimpleHeader } from '../../components/common';
|
||||||
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
@@ -33,6 +35,9 @@ function createAccountDeletionStyles(colors: AppColors) {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
keyboardView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingVertical: spacing.lg,
|
paddingVertical: spacing.lg,
|
||||||
},
|
},
|
||||||
@@ -41,53 +46,153 @@ function createAccountDeletionStyles(colors: AppColors) {
|
|||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
},
|
||||||
section: {
|
|
||||||
marginBottom: spacing['2xl'],
|
// 顶部叙述区:左对齐、有呼吸感
|
||||||
|
heroSection: {
|
||||||
|
paddingHorizontal: spacing['2xl'],
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
paddingBottom: spacing.xl,
|
||||||
},
|
},
|
||||||
// 警告卡片 - 扁平化风格
|
heroEyebrow: {
|
||||||
warningCard: {
|
fontSize: 13,
|
||||||
backgroundColor: colors.error.light + '20',
|
color: colors.text.hint,
|
||||||
borderRadius: 14,
|
letterSpacing: 1,
|
||||||
padding: spacing.lg,
|
|
||||||
marginHorizontal: spacing['2xl'],
|
|
||||||
marginBottom: spacing.xl,
|
|
||||||
},
|
|
||||||
warningTitle: {
|
|
||||||
fontWeight: '700',
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
color: colors.error.main,
|
|
||||||
},
|
},
|
||||||
warningText: {
|
heroTitle: {
|
||||||
color: colors.error.dark,
|
fontSize: 24,
|
||||||
marginBottom: spacing.xs,
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
},
|
|
||||||
// 状态卡片 - 扁平化风格
|
|
||||||
statusCard: {
|
|
||||||
backgroundColor: colors.warning.light + '30',
|
|
||||||
borderRadius: 14,
|
|
||||||
padding: spacing.lg,
|
|
||||||
marginHorizontal: spacing['2xl'],
|
|
||||||
marginBottom: spacing.xl,
|
|
||||||
},
|
|
||||||
statusTitle: {
|
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
fontSize: fontSizes.md,
|
color: colors.text.primary,
|
||||||
|
lineHeight: 32,
|
||||||
marginBottom: spacing.sm,
|
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,
|
fontSize: 32,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: colors.warning.dark,
|
color: colors.warning.dark,
|
||||||
textAlign: 'center',
|
lineHeight: 36,
|
||||||
marginVertical: spacing.md,
|
|
||||||
},
|
},
|
||||||
// 内容区域
|
countdownUnit: {
|
||||||
sectionContent: {
|
fontSize: 11,
|
||||||
marginHorizontal: spacing['2xl'],
|
color: colors.text.secondary,
|
||||||
marginBottom: spacing.xl,
|
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: {
|
sectionTitle: {
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@@ -95,47 +200,40 @@ function createAccountDeletionStyles(colors: AppColors) {
|
|||||||
color: colors.text.secondary,
|
color: colors.text.secondary,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
marginBottom: spacing.md,
|
|
||||||
},
|
},
|
||||||
listItem: {
|
|
||||||
|
// 输入框
|
||||||
|
inputWrapper: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'center',
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
listBullet: {
|
|
||||||
marginRight: spacing.sm,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
// 输入框 - 扁平化风格
|
|
||||||
input: {
|
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
padding: spacing.md,
|
paddingHorizontal: spacing.lg,
|
||||||
fontSize: 16,
|
height: 56,
|
||||||
|
marginHorizontal: spacing['2xl'],
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
inputIcon: {
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
|
fontSize: fontSizes.md,
|
||||||
height: 56,
|
height: 56,
|
||||||
},
|
},
|
||||||
// 按钮行
|
eyeButton: {
|
||||||
|
padding: 4,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 按钮行:次按钮在左、危险按钮在右,与全站保持一致
|
||||||
buttonRow: {
|
buttonRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
marginTop: spacing.lg,
|
marginTop: spacing.lg,
|
||||||
marginHorizontal: spacing['2xl'],
|
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: {
|
secondaryButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
height: 56,
|
height: 56,
|
||||||
@@ -165,11 +263,38 @@ function createAccountDeletionStyles(colors: AppColors) {
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
buttonDisabled: {
|
buttonDisabled: {
|
||||||
opacity: 0.6,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
cancelButton: {
|
primaryButton: {
|
||||||
marginTop: spacing.lg,
|
height: 56,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: colors.primary.main,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
marginHorizontal: spacing['2xl'],
|
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 [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md;
|
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 responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||||
|
|
||||||
const loadStatus = useCallback(async () => {
|
const loadStatus = useCallback(async () => {
|
||||||
@@ -284,121 +409,244 @@ export const AccountDeletionScreen: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 待注销状态
|
||||||
|
if (status?.is_pending_deletion) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
<SimpleHeader title="注销账号" onBack={() => router.back()} />
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.scrollContent,
|
||||||
|
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||||
|
]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* 顶部叙述 */}
|
||||||
|
<View style={styles.heroSection}>
|
||||||
|
<Text style={styles.heroEyebrow}>ACCOUNT · 注销申请中</Text>
|
||||||
|
<Text style={styles.heroTitle}>我们将在倒计时结束后清除你的账号</Text>
|
||||||
|
<Text style={styles.heroDesc}>
|
||||||
|
在此期间,你随时可以撤销申请,账号会立即恢复正常使用。
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 倒计时 */}
|
||||||
|
<View style={styles.countdownSection}>
|
||||||
|
<View style={styles.countdownRing}>
|
||||||
|
<Text style={styles.countdownNumber}>{status.cool_down_days || 90}</Text>
|
||||||
|
<Text style={styles.countdownUnit}>天</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.countdownTextWrap}>
|
||||||
|
<Text style={styles.countdownTitle}>距离永久删除</Text>
|
||||||
|
<Text style={styles.countdownDesc}>
|
||||||
|
再次登录或点击下方按钮可立即取消注销。
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 取消按钮 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.primaryButton,
|
||||||
|
submitting && styles.buttonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleCancelDeletion}
|
||||||
|
disabled={submitting}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.text.inverse} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.primaryButtonText}>撤销注销申请</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 页脚 */}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={styles.footerText}>
|
||||||
|
如有疑虑可联系<Text style={styles.footerLink}> 客服 </Text>获取帮助
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常状态:申请注销
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
<SimpleHeader title="注销账号" onBack={() => router.back()} />
|
<SimpleHeader title="注销账号" onBack={() => router.back()} />
|
||||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
<KeyboardAvoidingView
|
||||||
<View style={styles.content}>
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
{status?.is_pending_deletion ? (
|
style={styles.keyboardView}
|
||||||
<View style={styles.section}>
|
>
|
||||||
<View style={styles.statusCard}>
|
<ScrollView
|
||||||
<Text variant="body" style={styles.statusTitle}>
|
contentContainerStyle={[
|
||||||
账号注销申请中
|
styles.scrollContent,
|
||||||
</Text>
|
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||||
<Text variant="body" color={colors.text.secondary}>
|
]}
|
||||||
您的账号将在以下天数后永久删除:
|
showsVerticalScrollIndicator={false}
|
||||||
</Text>
|
keyboardShouldPersistTaps="handled"
|
||||||
<Text variant="body" style={styles.daysText}>
|
>
|
||||||
{status.cool_down_days || 90} 天
|
<View style={styles.content}>
|
||||||
</Text>
|
{/* 顶部叙述 */}
|
||||||
<Text variant="caption" color={colors.text.secondary}>
|
<View style={styles.heroSection}>
|
||||||
在此期间,您可以通过重新登录或点击下方按钮来取消注销申请。
|
<Text style={styles.heroEyebrow}>ACCOUNT · 注销</Text>
|
||||||
</Text>
|
<Text style={styles.heroTitle}>在离开之前,我们想让你知道这些</Text>
|
||||||
<TouchableOpacity
|
<Text style={styles.heroDesc}>
|
||||||
style={[styles.secondaryButton, styles.cancelButton, submitting && styles.buttonDisabled]}
|
注销并非立即生效,提交后你有 90 天的冷静期,反悔了随时可以回来。
|
||||||
onPress={handleCancelDeletion}
|
</Text>
|
||||||
disabled={submitting}
|
</View>
|
||||||
>
|
|
||||||
{submitting ? (
|
{/* 注销影响 - 列表式(无背景) */}
|
||||||
<ActivityIndicator size="small" color={colors.text.primary} />
|
<View style={styles.guideSection}>
|
||||||
) : (
|
<Text style={styles.guideLabel}>注销后会发生什么</Text>
|
||||||
<Text style={styles.secondaryButtonText}>取消注销申请</Text>
|
|
||||||
)}
|
<View style={styles.guideItem}>
|
||||||
</TouchableOpacity>
|
<MaterialCommunityIcons
|
||||||
|
name="account-remove-outline"
|
||||||
|
size={22}
|
||||||
|
color={colors.error.main}
|
||||||
|
style={styles.guideIcon}
|
||||||
|
/>
|
||||||
|
<View style={styles.guideTextWrap}>
|
||||||
|
<Text style={styles.guideTitle}>个人资料会被清除</Text>
|
||||||
|
<Text style={styles.guideDesc}>头像、昵称、简介等所有个人信息都将被永久删除。</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.guideDivider} />
|
||||||
|
|
||||||
|
<View style={styles.guideItem}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="message-text-outline"
|
||||||
|
size={22}
|
||||||
|
color={colors.warning.main}
|
||||||
|
style={styles.guideIcon}
|
||||||
|
/>
|
||||||
|
<View style={styles.guideTextWrap}>
|
||||||
|
<Text style={styles.guideTitle}>历史内容会保留</Text>
|
||||||
|
<Text style={styles.guideDesc}>你发布的帖子、评论将保留,但作者会显示为「已注销用户」。</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.guideDivider} />
|
||||||
|
|
||||||
|
<View style={styles.guideItem}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="heart-broken-outline"
|
||||||
|
size={22}
|
||||||
|
color={colors.error.main}
|
||||||
|
style={styles.guideIcon}
|
||||||
|
/>
|
||||||
|
<View style={styles.guideTextWrap}>
|
||||||
|
<Text style={styles.guideTitle}>关注关系被解绑</Text>
|
||||||
|
<Text style={styles.guideDesc}>你关注的人、粉丝、收藏、点赞等社交关系会被一并清除。</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.guideDivider} />
|
||||||
|
|
||||||
|
<View style={styles.guideItem}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="clock-time-four-outline"
|
||||||
|
size={22}
|
||||||
|
color={colors.text.secondary}
|
||||||
|
style={styles.guideIcon}
|
||||||
|
/>
|
||||||
|
<View style={styles.guideTextWrap}>
|
||||||
|
<Text style={styles.guideTitle}>90 天冷静期</Text>
|
||||||
|
<Text style={styles.guideDesc}>期间重新登录即可撤销申请,账号会立刻恢复。</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
|
||||||
<View style={styles.section}>
|
{/* 红色提示 - 左侧细线代替大色块 */}
|
||||||
{/* 警告卡片 */}
|
<View style={styles.noticeSection}>
|
||||||
<View style={styles.warningCard}>
|
<View style={styles.noticeBar} />
|
||||||
<Text variant="body" style={styles.warningTitle}>
|
<View style={styles.noticeTextWrap}>
|
||||||
警告:账号注销后将无法恢复
|
<Text style={styles.noticeTitle}>这是不可恢复的操作</Text>
|
||||||
|
<Text style={styles.noticeDesc}>
|
||||||
|
90 天后所有数据将被永久删除,届时无法通过任何方式找回。请确认你已备份好需要保留的内容。
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="body" style={styles.warningText}>
|
</View>
|
||||||
注销后,您的账号将在90天后永久删除。
|
</View>
|
||||||
</Text>
|
|
||||||
<Text variant="body" style={styles.warningText}>
|
{/* 密码确认 */}
|
||||||
在此期间,您可以通过重新登录来取消注销。
|
<View style={styles.formSection}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<Text variant="caption" style={styles.sectionTitle}>
|
||||||
|
身份验证
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 注销说明 */}
|
<View style={styles.inputWrapper}>
|
||||||
<View style={styles.sectionContent}>
|
<MaterialCommunityIcons
|
||||||
<Text style={styles.sectionTitle}>注销后将发生什么</Text>
|
name="lock-outline"
|
||||||
<View style={styles.listItem}>
|
size={20}
|
||||||
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
|
color={colors.text.secondary}
|
||||||
<Text variant="body" color={colors.text.secondary}>
|
style={styles.inputIcon}
|
||||||
您的个人资料将被删除
|
/>
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.listItem}>
|
|
||||||
<MaterialCommunityIcons name="information" size={16} color={colors.warning.main} style={styles.listBullet} />
|
|
||||||
<Text variant="body" color={colors.text.secondary}>
|
|
||||||
您发布的帖子、评论将保留,但显示为「已注销用户」
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.listItem}>
|
|
||||||
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
|
|
||||||
<Text variant="body" color={colors.text.secondary}>
|
|
||||||
您的关注、粉丝关系将被清除
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.listItem}>
|
|
||||||
<MaterialCommunityIcons name="close-circle" size={16} color={colors.error.main} style={styles.listBullet} />
|
|
||||||
<Text variant="body" color={colors.text.secondary}>
|
|
||||||
您的收藏、点赞记录将被删除
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 密码确认 */}
|
|
||||||
<View style={styles.sectionContent}>
|
|
||||||
<Text style={styles.sectionTitle}>请输入密码确认</Text>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="请输入密码"
|
placeholder="请输入登录密码以确认"
|
||||||
placeholderTextColor={colors.text.hint}
|
placeholderTextColor={colors.text.hint}
|
||||||
secureTextEntry
|
secureTextEntry={!showPassword}
|
||||||
value={password}
|
value={password}
|
||||||
onChangeText={setPassword}
|
onChangeText={setPassword}
|
||||||
|
autoCapitalize="none"
|
||||||
|
returnKeyType="done"
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 按钮行 */}
|
|
||||||
<View style={styles.buttonRow}>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.secondaryButton}
|
onPress={() => setShowPassword(!showPassword)}
|
||||||
onPress={() => router.back()}
|
style={styles.eyeButton}
|
||||||
>
|
>
|
||||||
<Text style={styles.secondaryButtonText}>返回</Text>
|
<MaterialCommunityIcons
|
||||||
</TouchableOpacity>
|
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||||
<TouchableOpacity
|
size={20}
|
||||||
style={[styles.dangerButton, submitting && styles.buttonDisabled]}
|
color={colors.text.hint}
|
||||||
onPress={handleRequestDeletion}
|
/>
|
||||||
disabled={submitting || !password.trim()}
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<ActivityIndicator size="small" color={colors.text.inverse} />
|
|
||||||
) : (
|
|
||||||
<Text style={styles.dangerButtonText}>确认注销</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
|
||||||
</View>
|
{/* 按钮行 */}
|
||||||
</ScrollView>
|
<View style={styles.buttonRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.secondaryButton}
|
||||||
|
onPress={() => router.back()}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.secondaryButtonText}>再想想</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.dangerButton,
|
||||||
|
(submitting || !password.trim()) && styles.buttonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleRequestDeletion}
|
||||||
|
disabled={submitting || !password.trim()}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.text.inverse} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.dangerButtonText}>确认申请注销</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 页脚 */}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={styles.footerText}>
|
||||||
|
遇到问题?可以联系<Text style={styles.footerLink}> 客服 </Text>
|
||||||
|
我们会帮你处理
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 }[] = [
|
const syncModeOptions: { mode: BackgroundSyncMode; title: string; subtitle: string; icon: string }[] = [
|
||||||
{
|
{
|
||||||
mode: BackgroundSyncMode.DISABLED,
|
mode: BackgroundSyncMode.DISABLED,
|
||||||
|
|||||||
@@ -39,18 +39,28 @@ export interface VersionCheckResult {
|
|||||||
hasUpdate: boolean;
|
hasUpdate: boolean;
|
||||||
currentVersion: string;
|
currentVersion: string;
|
||||||
latestVersion: string;
|
latestVersion: string;
|
||||||
|
runtimeVersion: string;
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 比较版本号
|
* 比较语义版本号(SemVer 三段数字)
|
||||||
* 返回: 1 表示 v1 > v2, -1 表示 v1 < v2, 0 表示相等
|
* 返回: 1 表示 v1 > v2, -1 表示 v1 < v2, 0 表示相等
|
||||||
|
*
|
||||||
|
* 注意:仅做 x.y.z 形式的纯数字比较;任何一段非数字(NaN)会被当作 0 处理,
|
||||||
|
* 避免运行时版本号(commit count 等)混入时导致方向相反的误判。
|
||||||
*/
|
*/
|
||||||
function compareVersions(v1: string, v2: string): number {
|
function compareVersions(v1: string, v2: string): number {
|
||||||
const parts1 = v1.split('.').map(Number);
|
const parts1 = v1.split('.').map((s) => {
|
||||||
const parts2 = v2.split('.').map(Number);
|
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++) {
|
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||||
const p1 = parts1[i] || 0;
|
const p1 = parts1[i] || 0;
|
||||||
const p2 = parts2[i] || 0;
|
const p2 = parts2[i] || 0;
|
||||||
@@ -86,10 +96,10 @@ async function fetchLatestAPKVersion(): Promise<APKVersionInfo | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前应用版本
|
* 获取当前应用语义版本(来自 app.json / app.config.js 的 expo.version)
|
||||||
*/
|
*/
|
||||||
function getCurrentVersion(): string {
|
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 {
|
try {
|
||||||
|
// 文件名带语义版本(用户可读);runtimeVersion 已在 downloadUrl 中体现
|
||||||
const downloadPath = `${FileSystem.documentDirectory}with-you-${version}.apk`;
|
const downloadPath = `${FileSystem.documentDirectory}with-you-${version}.apk`;
|
||||||
|
|
||||||
// 显示下载进度
|
// 显示下载进度
|
||||||
@@ -247,15 +258,18 @@ export async function checkForAPKUpdate(force: boolean = false): Promise<Version
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentVersion = getCurrentVersion();
|
const currentVersion = getCurrentVersion();
|
||||||
|
// 远端必须返回独立的语义版本号;若缺失才回退到 runtimeVersion(不推荐但兜底)
|
||||||
const latestVersion = latestAPK.versionName || latestAPK.runtimeVersion;
|
const latestVersion = latestAPK.versionName || latestAPK.runtimeVersion;
|
||||||
|
const remoteRuntimeVersion = latestAPK.runtimeVersion;
|
||||||
|
|
||||||
// 比较版本
|
// 比较版本(仅基于语义版本,不受 buildNumber / runtimeVersion 影响)
|
||||||
const comparison = compareVersions(currentVersion, latestVersion);
|
const comparison = compareVersions(currentVersion, latestVersion);
|
||||||
|
|
||||||
const result: VersionCheckResult = {
|
const result: VersionCheckResult = {
|
||||||
hasUpdate: comparison < 0,
|
hasUpdate: comparison < 0,
|
||||||
currentVersion,
|
currentVersion,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
|
runtimeVersion: remoteRuntimeVersion,
|
||||||
downloadUrl: latestAPK.downloadUrl,
|
downloadUrl: latestAPK.downloadUrl,
|
||||||
size: latestAPK.size,
|
size: latestAPK.size,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export interface UploadResponse {
|
|||||||
height?: number;
|
height?: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
// 文件上传专用字段(POST /uploads/files 返回)
|
||||||
|
name?: string;
|
||||||
|
mime_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传服务类
|
// 上传服务类
|
||||||
@@ -130,22 +133,23 @@ class UploadService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传文件(通用)
|
// 上传文件(通用,对接后端 POST /uploads/files)
|
||||||
|
// 后端返回 { url, name, size, mime_type }
|
||||||
async uploadFile(
|
async uploadFile(
|
||||||
file: {
|
file: {
|
||||||
uri: string;
|
uri: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
},
|
},
|
||||||
folder: string = 'general'
|
folder: string = 'chat'
|
||||||
): Promise<UploadResponse | null> {
|
): Promise<UploadResponse | null> {
|
||||||
try {
|
try {
|
||||||
const response = await api.upload<UploadResponse>('/upload/file', {
|
const response = await api.upload<UploadResponse>('/uploads/files', {
|
||||||
uri: file.uri,
|
uri: file.uri,
|
||||||
name: file.name || `file_${Date.now()}`,
|
name: file.name || `file_${Date.now()}`,
|
||||||
type: file.type || 'application/octet-stream',
|
type: file.type || 'application/octet-stream',
|
||||||
}, { folder });
|
}, { folder });
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('上传文件失败:', error);
|
console.error('上传文件失败:', error);
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export interface FileSegmentData {
|
|||||||
name: string;
|
name: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
mime_type?: string;
|
mime_type?: string;
|
||||||
|
/** 文件已过期(已被服务端清理),前端据此显示失效状态 */
|
||||||
|
expired?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AtSegmentData {
|
export interface AtSegmentData {
|
||||||
|
|||||||
Reference in New Issue
Block a user