Compare commits
29
Commits
8828851631
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
477f83c5b1 | ||
|
|
9ccbdfead4 | ||
|
|
c6f98b5445 | ||
|
|
acf6356ba6 | ||
|
|
f8f0b3c4fd | ||
|
|
78c42eff53 | ||
|
|
716c2917d4 | ||
|
|
dc993a09d6 | ||
|
|
6d2e9c884c | ||
|
|
011ef82cd0 | ||
|
|
5fbf159369 | ||
|
|
cceb8a6ae4 | ||
|
|
af5ca8be73 | ||
|
|
b1ff485bda | ||
|
|
414e245fb4 | ||
|
|
85fa02f142 | ||
|
|
b15fed5b6a | ||
|
|
613a278505 | ||
|
|
294a7de61d | ||
|
|
979684a06e | ||
|
|
9bb45aac83 | ||
|
|
5f4a1d40ed | ||
|
|
5215ea2ea8 | ||
|
|
a1433f1f4e | ||
|
|
9a8c23e2f0 | ||
|
|
9f87914235 | ||
|
|
ef35dc748a | ||
|
|
ff55623d80 | ||
|
|
fefa2d74e6 |
@@ -0,0 +1,13 @@
|
||||
**
|
||||
!Dockerfile.backend
|
||||
!admin-web/**
|
||||
!backend/**
|
||||
|
||||
admin-web/node_modules/
|
||||
admin-web/dist/
|
||||
admin-web/.env*
|
||||
backend/**/bin/
|
||||
backend/**/obj/
|
||||
backend/**/TestResults/
|
||||
backend/MiaoJiZhang.Api/wwwroot/assets/
|
||||
backend/*.log
|
||||
+10
@@ -33,6 +33,8 @@ frontend/android/app/release/
|
||||
release/
|
||||
backups/
|
||||
filing/
|
||||
.backup-before-update-*/
|
||||
.deploy/
|
||||
*.apk
|
||||
*.sql
|
||||
*.pem
|
||||
@@ -48,6 +50,11 @@ frontend/android/app/libs/push/*.aar
|
||||
# Local tool metadata
|
||||
.agents/
|
||||
.codex/
|
||||
.codex-build/
|
||||
.codex-*-a/
|
||||
.codex-*-b/
|
||||
.codex-*.patch
|
||||
backend/.codex-tools/
|
||||
.git-local/
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -55,3 +62,6 @@ frontend/android/app/libs/push/*.aar
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
# Generated Admin Web static assets
|
||||
backend/MiaoJiZhang.Api/wwwroot/assets/*
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-bookworm-slim AS admin-build
|
||||
WORKDIR /src/admin-web
|
||||
COPY admin-web/package.json admin-web/package-lock.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --no-audit --no-fund
|
||||
COPY admin-web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS api-build
|
||||
WORKDIR /src
|
||||
COPY backend/MiaoJiZhang.sln backend/
|
||||
COPY backend/MiaoJiZhang.Api/MiaoJiZhang.Api.csproj backend/MiaoJiZhang.Api/
|
||||
COPY backend/MiaoJiZhang.Domain/MiaoJiZhang.Domain.csproj backend/MiaoJiZhang.Domain/
|
||||
COPY backend/MiaoJiZhang.Infrastructure/MiaoJiZhang.Infrastructure.csproj backend/MiaoJiZhang.Infrastructure/
|
||||
RUN --mount=type=cache,target=/root/.nuget/packages \
|
||||
dotnet restore backend/MiaoJiZhang.Api/MiaoJiZhang.Api.csproj
|
||||
COPY backend/ backend/
|
||||
ARG BUILD_VERSION=dev
|
||||
RUN --mount=type=cache,target=/root/.nuget/packages \
|
||||
dotnet publish backend/MiaoJiZhang.Api/MiaoJiZhang.Api.csproj \
|
||||
--configuration Release \
|
||||
--output /app/publish \
|
||||
--no-restore \
|
||||
--property:UseAppHost=false \
|
||||
--property:InformationalVersion="$BUILD_VERSION" \
|
||||
&& rm -rf /app/publish/wwwroot \
|
||||
&& mkdir -p /app/publish/wwwroot
|
||||
COPY --from=admin-build /src/admin-web/dist/ /app/publish/wwwroot/
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim AS runtime
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG VCS_REF=unknown
|
||||
ARG BUILD_DATE=unknown
|
||||
LABEL org.opencontainers.image.title="JiZhi Backend" \
|
||||
org.opencontainers.image.description="JiZhi admin web and ASP.NET backend" \
|
||||
org.opencontainers.image.version="$BUILD_VERSION" \
|
||||
org.opencontainers.image.revision="$VCS_REF" \
|
||||
org.opencontainers.image.created="$BUILD_DATE" \
|
||||
org.opencontainers.image.source="https://gitea.nxsir.cn/nanxun/jizhi"
|
||||
WORKDIR /app
|
||||
COPY --from=api-build --chown=$APP_UID:$APP_UID /app/publish/ ./
|
||||
ENV URLS=http://0.0.0.0:8080 \
|
||||
ASPNETCORE_URLS=http://0.0.0.0:8080 \
|
||||
ASPNETCORE_HTTP_PORTS=8080 \
|
||||
DOTNET_EnableDiagnostics=0
|
||||
USER $APP_UID
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["dotnet", "MiaoJiZhang.Api.dll"]
|
||||
Vendored
+71
-7
@@ -34,8 +34,10 @@ pipeline {
|
||||
https_proxy = 'http://192.168.5.200:7890'
|
||||
NO_PROXY = '127.0.0.1,localhost,192.168.5.8,.nxsir.cn,pub.flutter-io.cn,storage.flutter-io.cn'
|
||||
no_proxy = '127.0.0.1,localhost,192.168.5.8,.nxsir.cn,pub.flutter-io.cn,storage.flutter-io.cn'
|
||||
GRADLE_USER_HOME = "${WORKSPACE}/.ci/gradle"
|
||||
PUB_CACHE = "${WORKSPACE}/.ci/pub-cache"
|
||||
// Keep dependency caches outside cleanWs. The build node already owns
|
||||
// these standard user caches, while concurrent builds are disabled.
|
||||
GRADLE_USER_HOME = '/home/nanxunai/.gradle'
|
||||
PUB_CACHE = '/home/nanxunai/.pub-cache'
|
||||
OPENLIST_CREDENTIALS = 'openlist_key'
|
||||
OPENLIST_BASE_URL = 'https://openlist.nxsir.cn'
|
||||
OPENLIST_REMOTE_DIR = '/yidongpan/构建产物/jizhang'
|
||||
@@ -57,7 +59,10 @@ pipeline {
|
||||
if (versionParts.size() != 2) { error("Invalid pubspec version: ${env.PUBSPEC_VERSION}") }
|
||||
env.APP_VERSION = versionParts[0]
|
||||
env.BASE_VERSION_CODE = versionParts[1]
|
||||
env.ANDROID_VERSION_CODE = "${versionParts[1].toInteger() + env.BUILD_NUMBER.toInteger()}"
|
||||
env.FLUTTER_BUILD_NUMBER = "${versionParts[1].toInteger() + env.BUILD_NUMBER.toInteger()}"
|
||||
// Flutter's split-per-abi Gradle configuration assigns arm64-v8a
|
||||
// the 2xxx versionCode range while preserving the build-number suffix.
|
||||
env.ANDROID_VERSION_CODE = "${env.FLUTTER_BUILD_NUMBER.toInteger() + 2000}"
|
||||
env.SHORT_SHA = sh(script: 'git rev-parse --short=8 HEAD', returnStdout: true).trim()
|
||||
env.IMMUTABLE_TAG = "${env.APP_VERSION}-internal-b${env.BUILD_NUMBER}-${env.SHORT_SHA}"
|
||||
env.APK_BASENAME = "JiZhi-${env.IMMUTABLE_TAG}-vc${env.ANDROID_VERSION_CODE}-arm64-v8a.apk"
|
||||
@@ -95,6 +100,55 @@ PY
|
||||
set -euo pipefail
|
||||
"$FLUTTER_ROOT/bin/flutter" config --no-analytics
|
||||
"$FLUTTER_ROOT/bin/flutter" pub get
|
||||
'''
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
# sqlite3 native hooks use a workspace-local cache, which cleanWs
|
||||
# removes. Prime it from a verified node-local cache so a transient
|
||||
# GitHub connection failure cannot block tests or the Gradle build.
|
||||
sqlite_package_dir=$(python3 - <<'PY'
|
||||
import json, pathlib, urllib.parse
|
||||
config = json.loads(pathlib.Path('.dart_tool/package_config.json').read_text(encoding='utf-8'))
|
||||
package = next(item for item in config['packages'] if item['name'] == 'sqlite3')
|
||||
uri = urllib.parse.urlparse(package['rootUri'])
|
||||
if uri.scheme != 'file':
|
||||
raise SystemExit(f"unexpected sqlite3 package URI: {package['rootUri']}")
|
||||
print(pathlib.Path(urllib.parse.unquote(uri.path)).resolve())
|
||||
PY
|
||||
)
|
||||
sqlite_release=$(basename "$sqlite_package_dir")
|
||||
|
||||
prime_sqlcipher() {
|
||||
sqlite_asset="$1"
|
||||
sqlite_hash=$(sed -n "s/.*'$sqlite_asset': '\\([0-9a-f]\\{64\\}\\)'.*/\\1/p" \
|
||||
"$sqlite_package_dir/lib/src/hook/asset_hashes.dart")
|
||||
test "${#sqlite_hash}" -eq 64
|
||||
|
||||
sqlite_cache_dir="/home/nanxunai/.cache/jizhang/sqlite3/$sqlite_hash"
|
||||
sqlite_cache_file="$sqlite_cache_dir/libsqlcipher.so"
|
||||
sqlite_cache_tmp="$sqlite_cache_file.tmp"
|
||||
mkdir -p "$sqlite_cache_dir"
|
||||
if ! printf '%s %s\n' "$sqlite_hash" "$sqlite_cache_file" | sha256sum -c -; then
|
||||
rm -f "$sqlite_cache_tmp"
|
||||
curl --fail --location --retry 8 --retry-all-errors --retry-delay 3 \
|
||||
--connect-timeout 20 --max-time 600 \
|
||||
--output "$sqlite_cache_tmp" \
|
||||
"https://github.com/simolus3/sqlite3.dart/releases/download/$sqlite_release/$sqlite_asset"
|
||||
printf '%s %s\n' "$sqlite_hash" "$sqlite_cache_tmp" | sha256sum -c -
|
||||
mv -f "$sqlite_cache_tmp" "$sqlite_cache_file"
|
||||
fi
|
||||
|
||||
sqlite_hash_prefix=$(printf '%.8s' "$sqlite_hash")
|
||||
sqlite_hook_dir=".dart_tool/hooks_runner/shared/sqlite3/build/download-$sqlite_hash_prefix"
|
||||
mkdir -p "$sqlite_hook_dir"
|
||||
cp "$sqlite_cache_file" "$sqlite_hook_dir/libsqlcipher.so"
|
||||
}
|
||||
|
||||
prime_sqlcipher libsqlcipher.x64.linux.so
|
||||
prime_sqlcipher libsqlcipher.arm64.android.so
|
||||
'''
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
# Keep warnings/errors fatal while allowing the repository's existing
|
||||
# informational style lints to be cleaned up independently.
|
||||
"$FLUTTER_ROOT/bin/flutter" analyze --no-pub --no-fatal-infos
|
||||
@@ -113,12 +167,13 @@ PY
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
"$FLUTTER_ROOT/bin/flutter" build apk \
|
||||
--flavor internal --release --target-platform android-arm64 \
|
||||
--build-name "$APP_VERSION" --build-number "$ANDROID_VERSION_CODE" \
|
||||
--flavor internal --release --target-platform android-arm64 --split-per-abi \
|
||||
--build-name "$APP_VERSION" --build-number "$FLUTTER_BUILD_NUMBER" \
|
||||
--dart-define=INTERNAL_BUILD=true \
|
||||
--dart-define="API_BASE_URL=$API_BASE_URL" \
|
||||
--dart-define="APP_VERSION=$IMMUTABLE_TAG"
|
||||
source_apk=build/app/outputs/flutter-apk/app-internal-release.apk
|
||||
source_apk=$(find build/app/outputs/flutter-apk -maxdepth 1 -type f \
|
||||
-name '*arm64-v8a*internal*release.apk' -print -quit)
|
||||
test -s "$source_apk"
|
||||
cp "$source_apk" "$APK_PATH"
|
||||
'''
|
||||
@@ -172,6 +227,15 @@ PY
|
||||
|
||||
post {
|
||||
success { archiveArtifacts artifacts: 'artifacts/*.apk,artifacts/*.sha256,artifacts/*.json', fingerprint: true }
|
||||
always { cleanWs(deleteDirs: true, notFailBuild: true) }
|
||||
cleanup {
|
||||
script {
|
||||
// A build can be aborted while still waiting for an executor. In that
|
||||
// case Jenkins has no FilePath context and cleanWs would mask the
|
||||
// original result with MissingContextVariableException.
|
||||
if (env.NODE_NAME?.trim()) {
|
||||
cleanWs(deleteDirs: true, notFailBuild: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
pipeline {
|
||||
agent {
|
||||
node {
|
||||
label '构建机1'
|
||||
customWorkspace '/home/nanxunai/goujian/workspace/jizhang-backend-docker'
|
||||
}
|
||||
}
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout(true)
|
||||
timeout(time: 120, unit: 'MINUTES')
|
||||
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '20'))
|
||||
}
|
||||
|
||||
environment {
|
||||
PATH = '/usr/local/bin:/usr/bin:/bin'
|
||||
HTTP_PROXY = 'http://192.168.5.200:7890'
|
||||
HTTPS_PROXY = 'http://192.168.5.200:7890'
|
||||
http_proxy = 'http://192.168.5.200:7890'
|
||||
https_proxy = 'http://192.168.5.200:7890'
|
||||
NO_PROXY = '127.0.0.1,localhost,192.168.5.8,192.168.5.100,.nxsir.cn'
|
||||
no_proxy = '127.0.0.1,localhost,192.168.5.8,192.168.5.100,.nxsir.cn'
|
||||
OPENLIST_CREDENTIALS = 'openlist_key'
|
||||
OPENLIST_BASE_URL = 'https://openlist.nxsir.cn'
|
||||
OPENLIST_REMOTE_DIR = '/yidongpan/构建产物/jizhang/backend-docker/linux-amd64'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
deleteDir()
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
stage('Metadata And Preflight') {
|
||||
steps {
|
||||
script {
|
||||
env.SHORT_SHA = sh(script: 'git rev-parse --short=8 HEAD', returnStdout: true).trim()
|
||||
env.FULL_SHA = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
||||
env.BUILD_DATE = sh(script: 'date -u +%Y-%m-%dT%H:%M:%SZ', returnStdout: true).trim()
|
||||
env.BACKEND_VERSION = sh(script: 'date -u +%Y%m%d-%H%M', returnStdout: true).trim()
|
||||
env.IMMUTABLE_TAG = "${env.BACKEND_VERSION}-b${env.BUILD_NUMBER}-${env.SHORT_SHA}"
|
||||
env.IMAGE_REF = "jizhi-backend:${env.IMMUTABLE_TAG}"
|
||||
env.ARCHIVE_BASENAME = "JiZhi-Backend-${env.IMMUTABLE_TAG}-linux-amd64.tar.gz"
|
||||
env.ARCHIVE_PATH = "${env.WORKSPACE}/artifacts/${env.ARCHIVE_BASENAME}"
|
||||
env.MANIFEST_PATH = "${env.WORKSPACE}/artifacts/JiZhi-Backend-${env.IMMUTABLE_TAG}-manifest.json"
|
||||
env.SMOKE_NETWORK = "jizhi-smoke-net-${env.BUILD_NUMBER}"
|
||||
env.SMOKE_MYSQL = "jizhi-smoke-mysql-${env.BUILD_NUMBER}"
|
||||
env.SMOKE_APP = "jizhi-smoke-app-${env.BUILD_NUMBER}"
|
||||
env.DOCKER_CMD = sh(
|
||||
script: '''
|
||||
set +x
|
||||
if docker version >/dev/null 2>&1; then
|
||||
printf 'docker'
|
||||
elif sudo -n docker version >/dev/null 2>&1; then
|
||||
printf 'sudo -n docker'
|
||||
else
|
||||
echo 'Jenkins 用户无法访问 Docker;请加入 docker 组或配置免密 sudo docker。' >&2
|
||||
exit 1
|
||||
fi
|
||||
''',
|
||||
returnStdout: true,
|
||||
).trim()
|
||||
currentBuild.displayName = "#${env.BUILD_NUMBER} ${env.IMMUTABLE_TAG}"
|
||||
currentBuild.description = 'linux/amd64 · Admin Web + ASP.NET API'
|
||||
}
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
test "$(uname -m)" = x86_64
|
||||
for command_name in git docker curl gzip sha256sum python3; do
|
||||
command -v "$command_name" >/dev/null
|
||||
done
|
||||
$DOCKER_CMD version >/dev/null
|
||||
$DOCKER_CMD buildx version
|
||||
test -f Dockerfile.backend
|
||||
test -f admin-web/package-lock.json
|
||||
mkdir -p artifacts
|
||||
available_kb=$(df -Pk "$WORKSPACE" | awk 'NR == 2 { print $4 }')
|
||||
# Warm Docker layers consume disk but make incremental builds
|
||||
# much smaller; keep 4 GiB free for image export and smoke tests.
|
||||
test "$available_kb" -ge 4194304
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build linux-amd64 Image') {
|
||||
steps {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 6); do
|
||||
if $DOCKER_CMD buildx build \
|
||||
--platform linux/amd64 \
|
||||
--load \
|
||||
--file Dockerfile.backend \
|
||||
--build-arg "BUILD_VERSION=$IMMUTABLE_TAG" \
|
||||
--build-arg "VCS_REF=$FULL_SHA" \
|
||||
--build-arg "BUILD_DATE=$BUILD_DATE" \
|
||||
--tag "$IMAGE_REF" \
|
||||
.; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -ge 6 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
'''
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
test "$($DOCKER_CMD image inspect "$IMAGE_REF" --format '{{.Os}}/{{.Architecture}}')" = 'linux/amd64'
|
||||
test "$($DOCKER_CMD image inspect "$IMAGE_REF" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$FULL_SHA"
|
||||
$DOCKER_CMD run --rm --entrypoint /bin/sh "$IMAGE_REF" -c \
|
||||
'test -s /app/MiaoJiZhang.Api.dll && test -s /app/wwwroot/index.html'
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Smoke Test Image') {
|
||||
steps {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
$DOCKER_CMD network create "$SMOKE_NETWORK" >/dev/null
|
||||
$DOCKER_CMD run --detach \
|
||||
--name "$SMOKE_MYSQL" \
|
||||
--network "$SMOKE_NETWORK" \
|
||||
--env MYSQL_DATABASE=jizhi_smoke \
|
||||
--env MYSQL_USER=jizhi \
|
||||
--env MYSQL_PASSWORD=jizhi-smoke-password \
|
||||
--env MYSQL_ROOT_PASSWORD=jizhi-smoke-root-password \
|
||||
mysql:8.4 >/dev/null
|
||||
|
||||
mysql_ready=0
|
||||
for attempt in $(seq 1 60); do
|
||||
if $DOCKER_CMD exec "$SMOKE_MYSQL" mysqladmin ping \
|
||||
--host=127.0.0.1 --protocol=tcp \
|
||||
--user=jizhi --password=jizhi-smoke-password --silent; then
|
||||
mysql_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$mysql_ready" != 1 ]; then
|
||||
$DOCKER_CMD logs "$SMOKE_MYSQL" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
|
||||
host_port=$((18080 + BUILD_NUMBER % 1000))
|
||||
$DOCKER_CMD run --detach \
|
||||
--name "$SMOKE_APP" \
|
||||
--network "$SMOKE_NETWORK" \
|
||||
--publish "127.0.0.1:${host_port}:8080" \
|
||||
--env ASPNETCORE_ENVIRONMENT=Production \
|
||||
--env "ConnectionStrings__Default=Server=$SMOKE_MYSQL;Port=3306;Database=jizhi_smoke;User=jizhi;Password=jizhi-smoke-password" \
|
||||
--env Jwt__Secret=jizhi-smoke-jwt-secret-at-least-32-characters \
|
||||
--env Jwt__Issuer=MiaoJiZhang.Api \
|
||||
--env Jwt__Audience=MiaoJiZhang.App \
|
||||
--env Admin__BootstrapUsername=smoke_admin \
|
||||
--env Admin__BootstrapPassword=jizhi-smoke-admin-password \
|
||||
--env Admin__CookieSecure=false \
|
||||
--env "Build__Version=$IMMUTABLE_TAG" \
|
||||
"$IMAGE_REF" >/dev/null
|
||||
|
||||
app_ready=0
|
||||
for attempt in $(seq 1 90); do
|
||||
if curl --fail --silent --show-error \
|
||||
"http://127.0.0.1:$host_port/api/ping" >artifacts/smoke-ping.json; then
|
||||
app_ready=1
|
||||
break
|
||||
fi
|
||||
if ! $DOCKER_CMD inspect "$SMOKE_APP" --format '{{.State.Running}}' | grep -qx true; then
|
||||
$DOCKER_CMD logs "$SMOKE_APP" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$app_ready" != 1 ]; then
|
||||
$DOCKER_CMD logs "$SMOKE_APP" >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 - "$IMMUTABLE_TAG" <<'PY'
|
||||
import json, pathlib, sys
|
||||
payload = json.loads(pathlib.Path('artifacts/smoke-ping.json').read_text(encoding='utf-8'))
|
||||
assert payload.get('version') == sys.argv[1], payload
|
||||
PY
|
||||
curl --fail --silent --show-error "http://127.0.0.1:$host_port/" \
|
||||
| grep -Fq '<div id="app"></div>'
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Package Image') {
|
||||
steps {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
$DOCKER_CMD save "$IMAGE_REF" | gzip -1 >"$ARCHIVE_PATH"
|
||||
gzip -t "$ARCHIVE_PATH"
|
||||
gzip -dc "$ARCHIVE_PATH" | tar -tf - | grep -q '^manifest.json$'
|
||||
sha256sum "$ARCHIVE_PATH" >"$ARCHIVE_PATH.sha256"
|
||||
image_id=$($DOCKER_CMD image inspect "$IMAGE_REF" --format '{{.Id}}')
|
||||
archive_sha=$(awk '{print $1}' "$ARCHIVE_PATH.sha256")
|
||||
export image_id archive_sha
|
||||
python3 - <<'PY'
|
||||
import json, os, pathlib
|
||||
path = pathlib.Path(os.environ['MANIFEST_PATH'])
|
||||
payload = {
|
||||
'project': 'jizhang-backend',
|
||||
'buildNumber': os.environ['BUILD_NUMBER'],
|
||||
'version': os.environ['IMMUTABLE_TAG'],
|
||||
'commit': os.environ['FULL_SHA'],
|
||||
'platform': 'linux/amd64',
|
||||
'image': os.environ['IMAGE_REF'],
|
||||
'imageId': os.environ['image_id'],
|
||||
'archive': pathlib.Path(os.environ['ARCHIVE_PATH']).name,
|
||||
'archiveSha256': os.environ['archive_sha'],
|
||||
'builtAt': os.environ['BUILD_DATE'],
|
||||
'contents': ['admin-web', 'MiaoJiZhang.Api'],
|
||||
'smokeTests': ['image architecture', '/api/ping', 'admin web index'],
|
||||
}
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + chr(10), encoding='utf-8')
|
||||
PY
|
||||
sha256sum "$MANIFEST_PATH" >"$MANIFEST_PATH.sha256"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Upload To OpenList') {
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: "${OPENLIST_CREDENTIALS}", usernameVariable: 'OPENLIST_USERNAME', passwordVariable: 'OPENLIST_PASSWORD')]) {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
for artifact in \
|
||||
"$ARCHIVE_PATH" \
|
||||
"$ARCHIVE_PATH.sha256" \
|
||||
"$MANIFEST_PATH" \
|
||||
"$MANIFEST_PATH.sha256"; do
|
||||
./scripts/upload-openlist-artifact.sh "$artifact" "$OPENLIST_REMOTE_DIR"
|
||||
done
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
script {
|
||||
if (env.NODE_NAME?.trim() && env.DOCKER_CMD?.trim()) {
|
||||
sh(
|
||||
script: '''
|
||||
$DOCKER_CMD rm --force "$SMOKE_APP" "$SMOKE_MYSQL" >/dev/null 2>&1 || true
|
||||
$DOCKER_CMD network rm "$SMOKE_NETWORK" >/dev/null 2>&1 || true
|
||||
$DOCKER_CMD image rm "$IMAGE_REF" >/dev/null 2>&1 || true
|
||||
''',
|
||||
returnStatus: true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
success {
|
||||
archiveArtifacts artifacts: 'artifacts/*.json,artifacts/*.sha256', fingerprint: true
|
||||
}
|
||||
cleanup {
|
||||
script {
|
||||
if (env.NODE_NAME?.trim()) {
|
||||
cleanWs(deleteDirs: true, notFailBuild: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-24
@@ -5,7 +5,7 @@ import { adminAuth } from './auth'
|
||||
import { DashboardOutlined, SettingOutlined, ControlOutlined, SmileOutlined,
|
||||
GithubOutlined, PictureOutlined, TeamOutlined, AppstoreOutlined,
|
||||
NotificationOutlined, SafetyCertificateOutlined, AuditOutlined,
|
||||
LogoutOutlined } from '@ant-design/icons-vue'
|
||||
LogoutOutlined, CloudServerOutlined, FundOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -14,20 +14,29 @@ const selectedKeys = ref<string[]>([String(route.name)])
|
||||
|
||||
watch(() => route.name, (n) => { selectedKeys.value = [String(n)] })
|
||||
|
||||
const nav = computed(() => [
|
||||
{ key: 'Dashboard', icon: DashboardOutlined, label: '仪表盘' },
|
||||
{ key: 'Settings', icon: ControlOutlined, label: '系统设置' },
|
||||
{ key: 'Configs', icon: SettingOutlined, label: '品牌配置' },
|
||||
{ key: 'SysCategories', icon: AppstoreOutlined, label: '默认分类' },
|
||||
{ key: 'Personas', icon: SmileOutlined, label: 'AI 性格' },
|
||||
{ key: 'Avatars', icon: GithubOutlined, label: 'AI 形象' },
|
||||
{ key: 'Stickers', icon: PictureOutlined, label: '表情包库' },
|
||||
{ key: 'Users', icon: TeamOutlined, label: '用户管理' },
|
||||
{ key: 'PushCampaigns', icon: NotificationOutlined, label: '推送管理' },
|
||||
...(adminAuth.identity.value?.role === 'super_admin' ? [
|
||||
const navGroups = computed(() => [
|
||||
{ label: '概览', items: [
|
||||
{ key: 'Dashboard', icon: DashboardOutlined, label: '仪表盘' },
|
||||
] },
|
||||
{ label: 'AI 配置', items: [
|
||||
{ key: 'ModelService', icon: CloudServerOutlined, label: '模型服务' },
|
||||
{ key: 'Personas', icon: SmileOutlined, label: 'AI 性格' },
|
||||
{ key: 'Avatars', icon: GithubOutlined, label: 'AI 形象' },
|
||||
{ key: 'Stickers', icon: PictureOutlined, label: '表情包库' },
|
||||
] },
|
||||
{ label: '产品配置', items: [
|
||||
{ key: 'ProductBasic', icon: SettingOutlined, label: '品牌与基础设置' },
|
||||
{ key: 'FeatureLimits', icon: ControlOutlined, label: '功能与额度' },
|
||||
{ key: 'SysCategories', icon: AppstoreOutlined, label: '默认分类' },
|
||||
] },
|
||||
{ label: '运营', items: [
|
||||
{ key: 'Users', icon: TeamOutlined, label: '用户管理' },
|
||||
{ key: 'PushCampaigns', icon: NotificationOutlined, label: '推送管理' },
|
||||
] },
|
||||
...(adminAuth.identity.value?.role === 'super_admin' ? [{ label: '安全', items: [
|
||||
{ key: 'AdminAccounts', icon: SafetyCertificateOutlined, label: '管理员账号' },
|
||||
{ key: 'Audit', icon: AuditOutlined, label: '操作审计' },
|
||||
] : []),
|
||||
] }] : []),
|
||||
])
|
||||
|
||||
watch(adminAuth.identity, value => {
|
||||
@@ -43,20 +52,23 @@ async function logout() {
|
||||
<template>
|
||||
<router-view v-if="route.meta.public || route.name === 'ChangePassword'" />
|
||||
<a-layout v-else style="min-height: 100vh">
|
||||
<a-layout-sider v-model:collapsed="collapsed" collapsible theme="light" :width="200"
|
||||
style="border-right: 1px solid #f0f0f0">
|
||||
<div style="padding: 18px 20px; font-size: 16px; font-weight: 700; white-space: nowrap; overflow: hidden;">
|
||||
<span style="color:#25211E;margin-right:6px">✎</span>记之 Admin
|
||||
<a-layout-sider v-model:collapsed="collapsed" collapsible theme="light" :width="224"
|
||||
breakpoint="lg" class="app-sider">
|
||||
<div class="brand-lockup">
|
||||
<FundOutlined />
|
||||
<span>记之 Admin</span>
|
||||
</div>
|
||||
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" :style="{ borderRight: 0 }"
|
||||
@click="({key}: {key: string}) => router.push({name: key})">
|
||||
<a-menu-item v-for="n in nav" :key="n.key">
|
||||
<component :is="n.icon" />
|
||||
<span>{{ n.label }}</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item-group v-for="group in navGroups" :key="group.label" :title="group.label">
|
||||
<a-menu-item v-for="item in group.items" :key="item.key">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu-item-group>
|
||||
</a-menu>
|
||||
<div style="position:absolute;bottom:16px;left:16px;right:16px">
|
||||
<a-button type="text" block style="text-align:left" @click="logout">
|
||||
<div class="logout-area">
|
||||
<a-button type="text" block @click="logout">
|
||||
<template #icon><LogoutOutlined /></template>退出登录
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -68,9 +80,24 @@ async function logout() {
|
||||
<a-tag>{{ adminAuth.identity.value?.role }}</a-tag>
|
||||
</a-space>
|
||||
</a-layout-header>
|
||||
<a-layout-content style="margin: 18px 20px; padding: 20px; background: #fff; border-radius: 8px; min-height: 360px;">
|
||||
<a-layout-content class="app-content">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-sider { position: sticky; top: 0; height: 100vh; overflow: auto; border-right: 1px solid #eef0f3; }
|
||||
.brand-lockup { display: flex; align-items: center; gap: 10px; height: 60px; padding: 0 22px; overflow: hidden; color: #191f26; font-size: 16px; font-weight: 700; white-space: nowrap; }
|
||||
.brand-lockup :first-child { color: #00a67d; font-size: 20px; }
|
||||
.logout-area { position: sticky; bottom: 0; padding: 12px 16px 16px; background: #fff; }
|
||||
.logout-area .ant-btn { text-align: left; }
|
||||
.app-content { min-height: 360px; margin: 18px 20px; padding: 24px; border-radius: 14px; background: #fff; }
|
||||
:deep(.ant-menu-item-group-title) { padding: 18px 24px 6px; color: #8b949e; font-size: 11px; font-weight: 600; letter-spacing: .08em; }
|
||||
:deep(.ant-menu-item) { min-height: 42px; }
|
||||
:deep(.ant-layout-sider-collapsed .ant-menu-item-group-title) { height: 12px; padding: 6px 0; overflow: hidden; color: transparent; }
|
||||
@media (max-width: 760px) {
|
||||
.app-content { margin: 10px; padding: 16px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -56,6 +56,16 @@ export const api = {
|
||||
createSysCategory: (d: { name: string; iconKey: string; type: string }) => http.post('/api/admin/categories', d).then(r => r.data),
|
||||
updateSysCategory: (id: number, d: { name: string; iconKey: string; type: string }) => http.put(`/api/admin/categories/${id}`, d).then(r => r.data),
|
||||
deleteSysCategory: (id: number) => http.delete(`/api/admin/categories/${id}`),
|
||||
llmSettings: () => http.get('/api/admin/llm/settings').then(r => r.data),
|
||||
updateLlmSettings: (data: {
|
||||
protocol: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
maxTokens: number
|
||||
temperature: number
|
||||
}) => http.put('/api/admin/llm/settings', data).then(r => r.data),
|
||||
updateLlmApiKey: (apiKey: string) => http.put('/api/admin/llm/api-key', { apiKey }).then(r => r.data),
|
||||
deleteLlmApiKey: () => http.delete('/api/admin/llm/api-key').then(r => r.data),
|
||||
testLlm: () => http.post('/api/admin/llm/test').then(r => r.data),
|
||||
pushCampaigns: (params: { page: number; limit: number }) => http.get('/api/admin/push/campaigns', { params }).then(r => r.data),
|
||||
estimatePushCampaign: (data: any) => http.post('/api/admin/push/campaigns/estimate', data).then(r => r.data),
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { api } from '../api'
|
||||
|
||||
interface Config {
|
||||
id: number
|
||||
key: string
|
||||
value: string
|
||||
version: number
|
||||
}
|
||||
|
||||
export function useAdminConfigs(defaults: Record<string, string>) {
|
||||
const configs = ref<Record<string, Config>>({})
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await api.configs() as Config[]
|
||||
const map: Record<string, Config> = {}
|
||||
for (const config of list) map[config.key] = config
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
if (!map[key]) map[key] = { id: 0, key, value, version: 0 }
|
||||
}
|
||||
configs.value = map
|
||||
} catch (error: any) {
|
||||
message.error(readError(error, '配置加载失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function value(key: string) {
|
||||
return configs.value[key]?.value ?? defaults[key] ?? ''
|
||||
}
|
||||
|
||||
function numberValue(key: string) {
|
||||
const parsed = Number(value(key))
|
||||
return Number.isFinite(parsed) ? parsed : Number(defaults[key] ?? 0)
|
||||
}
|
||||
|
||||
function boolValue(key: string) {
|
||||
return value(key) === 'true'
|
||||
}
|
||||
|
||||
function setValue(key: string, next: string | number | boolean | null) {
|
||||
const normalized = String(next ?? '')
|
||||
const current = configs.value[key]
|
||||
if (current) current.value = normalized
|
||||
else configs.value[key] = { id: 0, key, value: normalized, version: 0 }
|
||||
}
|
||||
|
||||
async function save(keys: string[]) {
|
||||
saving.value = true
|
||||
try {
|
||||
for (const key of keys) {
|
||||
const config = configs.value[key]
|
||||
if (!config) continue
|
||||
const saved = config.id > 0
|
||||
? await api.updateConfig(config.id, config.value)
|
||||
: await api.createConfig(config.key, config.value)
|
||||
config.id = saved.id
|
||||
config.version = saved.version
|
||||
}
|
||||
message.success('配置已保存')
|
||||
} catch (error: any) {
|
||||
message.error(readError(error, '配置保存失败'))
|
||||
throw error
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, saving, value, numberValue, boolValue, setValue, save, load }
|
||||
}
|
||||
|
||||
export function readError(error: any, fallback: string) {
|
||||
return error?.response?.data?.detail ||
|
||||
error?.response?.data?.message ||
|
||||
error?.response?.data?.error ||
|
||||
error?.message || fallback
|
||||
}
|
||||
@@ -8,8 +8,11 @@ const router = createRouter({
|
||||
{ path: '/change-password', name: 'ChangePassword', component: () => import('../views/ChangePassword.vue') },
|
||||
{ path: '/', redirect: '/dashboard' },
|
||||
{ path: '/dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') },
|
||||
{ path: '/settings', name: 'Settings', component: () => import('../views/Settings.vue') },
|
||||
{ path: '/configs', name: 'Configs', component: () => import('../views/Configs.vue') },
|
||||
{ path: '/settings', redirect: '/ai/model' },
|
||||
{ path: '/configs', redirect: '/product/basic' },
|
||||
{ path: '/ai/model', name: 'ModelService', component: () => import('../views/ModelService.vue') },
|
||||
{ path: '/product/basic', name: 'ProductBasic', component: () => import('../views/ProductBasic.vue') },
|
||||
{ path: '/product/features', name: 'FeatureLimits', component: () => import('../views/FeatureLimits.vue') },
|
||||
{ path: '/categories', name: 'SysCategories', component: () => import('../views/SysCategories.vue') },
|
||||
{ path: '/personas', name: 'Personas', component: () => import('../views/Personas.vue') },
|
||||
{ path: '/avatars', name: 'Avatars', component: () => import('../views/Avatars.vue') },
|
||||
|
||||
@@ -28,7 +28,7 @@ async function load() {
|
||||
}
|
||||
|
||||
async function createAccount() {
|
||||
if (form.password.length < 12) return message.error('初始密码至少 12 位')
|
||||
if (form.password.length < 6 || form.password.length > 128) return message.error('初始密码必须为 6 到 128 位')
|
||||
await api.createAdminAccount(form)
|
||||
message.success('管理员已创建')
|
||||
createOpen.value = false
|
||||
@@ -50,7 +50,9 @@ async function update(account: Account, patch: Partial<Account>) {
|
||||
}
|
||||
|
||||
async function submitReset() {
|
||||
if (!resetTarget.value || resetPassword.value.length < 12) return message.error('新密码至少 12 位')
|
||||
if (!resetTarget.value || resetPassword.value.length < 6 || resetPassword.value.length > 128) {
|
||||
return message.error('新密码必须为 6 到 128 位')
|
||||
}
|
||||
await api.resetAdminPassword(resetTarget.value.id, resetPassword.value)
|
||||
message.success('密码已重置,现有会话已撤销')
|
||||
resetTarget.value = null
|
||||
@@ -110,7 +112,7 @@ onMounted(load)
|
||||
<a-modal v-model:open="createOpen" title="新建管理员" ok-text="创建" @ok="createAccount">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="用户名"><a-input v-model:value="form.username" /></a-form-item>
|
||||
<a-form-item label="初始密码"><a-input-password v-model:value="form.password" /></a-form-item>
|
||||
<a-form-item label="初始密码"><a-input-password v-model:value="form.password" :maxlength="128" /></a-form-item>
|
||||
<a-form-item label="角色">
|
||||
<a-select v-model:value="form.role">
|
||||
<a-select-option value="operator">operator</a-select-option>
|
||||
@@ -123,7 +125,7 @@ onMounted(load)
|
||||
|
||||
<a-modal :open="!!resetTarget" title="重置密码" ok-text="重置并撤销会话"
|
||||
@cancel="resetTarget = null" @ok="submitReset">
|
||||
<a-input-password v-model:value="resetPassword" placeholder="至少 12 位的新密码" />
|
||||
<a-input-password v-model:value="resetPassword" :maxlength="128" placeholder="6 到 128 位的新密码" />
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ const form = reactive({ currentPassword: '', newPassword: '', confirmPassword: '
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
if (form.newPassword.length < 12) return message.error('新密码至少 12 位')
|
||||
if (loading.value) return
|
||||
if (!form.currentPassword) return message.error('请输入当前密码')
|
||||
if (form.newPassword.length < 6 || form.newPassword.length > 128) {
|
||||
return message.error('新密码长度必须为 6 到 128 位')
|
||||
}
|
||||
if (form.newPassword !== form.confirmPassword) return message.error('两次输入的新密码不一致')
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -39,14 +43,19 @@ async function logout() {
|
||||
<a-input-password v-model:value="form.currentPassword" autocomplete="current-password" />
|
||||
</a-form-item>
|
||||
<a-form-item label="新密码" required>
|
||||
<a-input-password v-model:value="form.newPassword" autocomplete="new-password" />
|
||||
<a-input-password v-model:value="form.newPassword" autocomplete="new-password" :maxlength="128" />
|
||||
</a-form-item>
|
||||
<a-form-item label="确认新密码" required>
|
||||
<a-input-password v-model:value="form.confirmPassword" autocomplete="new-password" />
|
||||
<a-input-password
|
||||
v-model:value="form.confirmPassword"
|
||||
autocomplete="new-password"
|
||||
:maxlength="128"
|
||||
@pressEnter="submit"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-space style="width:100%;justify-content:flex-end">
|
||||
<a-button @click="logout">退出登录</a-button>
|
||||
<a-button type="primary" html-type="submit" :loading="loading">保存密码</a-button>
|
||||
<a-button type="primary" html-type="submit" :loading="loading" @click="submit">保存密码</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { formatShanghaiDate } from '../utils/time'
|
||||
|
||||
interface Config { id: number; key: string; value: string; version: number; updatedAt: string }
|
||||
|
||||
const list = ref<Config[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
// Grouped by prefix
|
||||
const configMeta: Record<string, { label: string; desc: string; type: 'text' | 'number' | 'url' | 'switch' | 'select'; options?: string[] }> = {
|
||||
'brand.app_name': { label: 'App 名称', desc: 'App 内展示名称', type: 'text' },
|
||||
'brand.slogan': { label: 'App 标语', desc: '启动页/关于页口号', type: 'text' },
|
||||
'brand.logo_url': { label: 'Logo URL', desc: '品牌 Logo 远程地址', type: 'url' },
|
||||
'limit.daily_ai_messages': { label: '全局日限额', desc: '全站每日 AI 消息上限', type: 'number' },
|
||||
'limit.daily_ai_messages_per_user': { label: '每人日限额', desc: '单用户每日 AI 消息上限', type: 'number' },
|
||||
'limit.max_monthly_budget': { label: '最大月预算', desc: '用户可设置的最高月预算金额', type: 'number' },
|
||||
'feature.ocr_enabled': { label: 'OCR 拍照识别', desc: '是否开放 OCR 小票识别功能', type: 'switch' },
|
||||
'feature.voice_enabled': { label: '语音输入', desc: '是否开放语音记账功能', type: 'switch' },
|
||||
'feature.ai_auto_book': { label: 'AI 自动入账', desc: 'AI 识别记账意图后是否直接写库', type: 'switch' },
|
||||
'feature.sticker_enabled': { label: '表情包功能', desc: '是否开放表情包面板和 AI 表情回复', type: 'switch' },
|
||||
'system.default_ledger_name': { label: '默认账本名', desc: '新用户注册时自动创建', type: 'text' },
|
||||
'system.max_ledgers_per_user': { label: '每人最多账本', desc: '单用户可创建账本上限', type: 'number' },
|
||||
}
|
||||
|
||||
const groups = [
|
||||
{ key: 'brand', label: '品牌', prefix: 'brand.' },
|
||||
{ key: 'limit', label: '限额', prefix: 'limit.' },
|
||||
{ key: 'feature', label: '功能开关', prefix: 'feature.' },
|
||||
{ key: 'system', label: '系统', prefix: 'system.' },
|
||||
]
|
||||
|
||||
const grouped = computed(() => {
|
||||
const map: Record<string, Config[]> = {}
|
||||
for (const cfg of list.value) {
|
||||
const g = groups.find(g => cfg.key.startsWith(g.prefix))
|
||||
const k = g?.key || 'other'
|
||||
if (!map[k]) map[k] = []
|
||||
map[k].push(cfg)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
onMounted(refresh)
|
||||
async function refresh() { loading.value = true; try { list.value = await api.configs() } finally { loading.value = false } }
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editItem = ref<Config | null>(null)
|
||||
const editValue = ref('')
|
||||
const editingMeta = computed(() => editItem.value ? configMeta[editItem.value.key] : null)
|
||||
|
||||
function openEdit(cfg: Config) { editItem.value = cfg; editValue.value = cfg.value; editVisible.value = true }
|
||||
async function saveEdit() {
|
||||
if (!editItem.value) return
|
||||
await api.updateConfig(editItem.value.id, editValue.value)
|
||||
message.success(`已更新 ${editItem.value.key}`)
|
||||
editVisible.value = false
|
||||
refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
|
||||
<h2 style="margin:0">品牌配置</h2>
|
||||
<a-button @click="refresh">刷新</a-button>
|
||||
</div>
|
||||
|
||||
<a-tabs>
|
||||
<a-tab-pane v-for="g in groups" :key="g.key" :tab="g.label">
|
||||
<a-row :gutter="[16,12]">
|
||||
<a-col v-for="cfg in grouped[g.key]" :key="cfg.id" :span="8">
|
||||
<a-card size="small" hoverable @click="openEdit(cfg)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:2px">{{ configMeta[cfg.key]?.label || cfg.key }}</div>
|
||||
<div style="color:#999;font-size:11px;margin-bottom:6px">{{ configMeta[cfg.key]?.desc || '' }}</div>
|
||||
</div>
|
||||
<a-tag color="blue" style="margin-left:8px">v{{ cfg.version }}</a-tag>
|
||||
</div>
|
||||
<div v-if="configMeta[cfg.key]?.type === 'switch'"
|
||||
style="margin-top:6px;font-size:18px">
|
||||
<span v-if="cfg.value === 'true'" style="color:#00B386">✅ 已开启</span>
|
||||
<span v-else style="color:#ccc">❌ 已关闭</span>
|
||||
</div>
|
||||
<div v-else style="margin-top:6px;font-size:16px;font-weight:700;word-break:break-all">
|
||||
{{ cfg.key.includes('key') ? '••••••••' : cfg.value || '(空)' }}
|
||||
</div>
|
||||
<div style="color:#999;font-size:10px;margin-top:4px">{{ formatShanghaiDate(cfg.updatedAt) }}</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<a-modal v-model:open="editVisible" :title="`编辑配置: ${editItem?.key}`" @ok="saveEdit" :width="440">
|
||||
<div style="margin-bottom:10px;color:#999;font-size:12px">{{ editingMeta?.desc }}</div>
|
||||
<template v-if="editingMeta?.type === 'switch'">
|
||||
<a-switch
|
||||
:checked="editValue === 'true'"
|
||||
@change="(v: boolean) => editValue = String(v)"
|
||||
checked-children="开启" un-checked-children="关闭" />
|
||||
</template>
|
||||
<template v-else-if="editingMeta?.type === 'number'">
|
||||
<a-input-number v-model:value="(editValue as any)" style="width:100%" />
|
||||
</template>
|
||||
<template v-else-if="editingMeta?.type === 'select' && editingMeta.options">
|
||||
<a-select v-model:value="editValue" style="width:100%" :options="editingMeta.options.map(o=>({value:o,label:o}))" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-input v-model:value="editValue" />
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { ControlOutlined, SafetyCertificateOutlined, ThunderboltOutlined } from '@ant-design/icons-vue'
|
||||
import { useAdminConfigs } from '../composables/useAdminConfigs'
|
||||
|
||||
const limitKeys = ['limit.daily_ai_messages', 'limit.daily_ai_messages_per_user', 'limit.max_monthly_budget']
|
||||
const featureKeys = ['feature.ocr_enabled', 'feature.voice_enabled', 'feature.ai_auto_book', 'feature.sticker_enabled', 'feature.screenshot_bookkeeping_enabled']
|
||||
const onboardingKeys = ['permission.default.ai_enabled', 'quota.default_ai_chat_limit', 'quota.default_ai_chat_period']
|
||||
const keys = [...limitKeys, ...featureKeys, ...onboardingKeys]
|
||||
const config = useAdminConfigs({
|
||||
'limit.daily_ai_messages': '200',
|
||||
'limit.daily_ai_messages_per_user': '50',
|
||||
'limit.max_monthly_budget': '99999999',
|
||||
'feature.ocr_enabled': 'true',
|
||||
'feature.voice_enabled': 'true',
|
||||
'feature.ai_auto_book': 'true',
|
||||
'feature.sticker_enabled': 'true',
|
||||
'feature.screenshot_bookkeeping_enabled': 'true',
|
||||
'permission.default.ai_enabled': 'true',
|
||||
'quota.default_ai_chat_limit': '50',
|
||||
'quota.default_ai_chat_period': 'day',
|
||||
})
|
||||
const features = [
|
||||
{ key: 'feature.ocr_enabled', label: 'OCR 小票识别', detail: '允许拍照提取账单信息' },
|
||||
{ key: 'feature.voice_enabled', label: '语音记账', detail: '允许使用语音输入记账' },
|
||||
{ key: 'feature.ai_auto_book', label: 'AI 自动入账', detail: '识别到明确记账意图后直接入账' },
|
||||
{ key: 'feature.sticker_enabled', label: '表情包功能', detail: '开放聊天表情面板与 AI 表情回复' },
|
||||
{ key: 'feature.screenshot_bookkeeping_enabled', label: '截图自动记账', detail: '允许 Android 截图识别与自动记账' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<header class="page-heading">
|
||||
<div><h1>功能与额度</h1><p>集中管理能力开关、全局成本阈值和新用户默认 AI 权限。</p></div>
|
||||
<a-button type="primary" :loading="config.saving.value" @click="config.save(keys)">保存设置</a-button>
|
||||
</header>
|
||||
<a-skeleton v-if="config.loading.value" active :paragraph="{ rows: 10 }" />
|
||||
<section v-else class="settings-panel">
|
||||
<div class="section-heading"><ThunderboltOutlined /><div><h2>功能开关</h2><p>关闭后对应入口和服务能力将不可用。</p></div></div>
|
||||
<div class="switch-list">
|
||||
<div v-for="item in features" :key="item.key" class="switch-row">
|
||||
<div><strong>{{ item.label }}</strong><span>{{ item.detail }}</span></div>
|
||||
<a-switch :checked="config.boolValue(item.key)" @change="(value: boolean) => config.setValue(item.key, value)" />
|
||||
</div>
|
||||
</div>
|
||||
<a-divider />
|
||||
<div class="section-heading"><ControlOutlined /><div><h2>全局限制</h2><p>限制 AI 调用量和用户可设置的预算边界。</p></div></div>
|
||||
<a-form layout="vertical" class="form-grid">
|
||||
<a-form-item label="全站每日 AI 消息上限">
|
||||
<a-input-number :value="config.numberValue('limit.daily_ai_messages')" :min="0" style="width:100%" @change="(value: number | null) => config.setValue('limit.daily_ai_messages', value)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="每人每日 AI 消息上限">
|
||||
<a-input-number :value="config.numberValue('limit.daily_ai_messages_per_user')" :min="0" style="width:100%" @change="(value: number | null) => config.setValue('limit.daily_ai_messages_per_user', value)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="最大月预算金额">
|
||||
<a-input-number :value="config.numberValue('limit.max_monthly_budget')" :min="0" style="width:100%" @change="(value: number | null) => config.setValue('limit.max_monthly_budget', value)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider />
|
||||
<div class="section-heading"><SafetyCertificateOutlined /><div><h2>新用户 AI 权限</h2><p>只应用于保存后注册的新用户,现有用户权限不变。</p></div></div>
|
||||
<a-form layout="vertical" class="form-grid">
|
||||
<a-form-item label="默认启用 AI">
|
||||
<a-switch :checked="config.boolValue('permission.default.ai_enabled')" @change="(value: boolean) => config.setValue('permission.default.ai_enabled', value)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="默认对话次数" extra="0 表示不限次数。">
|
||||
<a-input-number :value="config.numberValue('quota.default_ai_chat_limit')" :min="0" :max="1000000" style="width:100%" @change="(value: number | null) => config.setValue('quota.default_ai_chat_limit', value)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="额度重置周期">
|
||||
<a-select :value="config.value('quota.default_ai_chat_period')" @change="(value: string) => config.setValue('quota.default_ai_chat_period', value)">
|
||||
<a-select-option value="day">每天</a-select-option>
|
||||
<a-select-option value="week">每周(周一开始)</a-select-option>
|
||||
<a-select-option value="month">每月</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page { max-width: 1040px; margin: 0 auto; color: #191f26; }
|
||||
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
|
||||
.page-heading h1 { margin: 0 0 6px; font-size: 24px; }
|
||||
.page-heading p, .section-heading p { margin: 0; color: #5e6772; line-height: 1.6; }
|
||||
.settings-panel { padding: 24px; border: 1px solid #eef0f3; border-radius: 14px; }
|
||||
.section-heading { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 20px; }
|
||||
.section-heading > :first-child { margin-top: 3px; color: #00a67d; font-size: 20px; }
|
||||
.section-heading h2 { margin: 0 0 4px; font-size: 17px; }
|
||||
.switch-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 16px; }
|
||||
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 16px; border-radius: 12px; background: #f6f7f9; }
|
||||
.switch-row strong, .switch-row span { display: block; }
|
||||
.switch-row span { margin-top: 3px; color: #5e6772; font-size: 12px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
|
||||
@media (max-width: 760px) { .page-heading { flex-direction: column; } .switch-list, .form-grid { grid-template-columns: 1fr; } .settings-panel { padding: 18px; } }
|
||||
</style>
|
||||
@@ -10,11 +10,16 @@ const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
if (!form.username.trim() || !form.password) return
|
||||
if (loading.value) return
|
||||
const username = form.username.trim()
|
||||
if (!username || !form.password) {
|
||||
error.value = '请输入用户名和密码'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const user = await adminAuth.login(form.username, form.password)
|
||||
const user = await adminAuth.login(username, form.password)
|
||||
if (user.mustChangePassword) {
|
||||
await router.replace('/change-password')
|
||||
} else {
|
||||
@@ -40,9 +45,14 @@ async function submit() {
|
||||
<a-input v-model:value="form.username" autocomplete="username" size="large" autofocus />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" required>
|
||||
<a-input-password v-model:value="form.password" autocomplete="current-password" size="large" />
|
||||
<a-input-password
|
||||
v-model:value="form.password"
|
||||
autocomplete="current-password"
|
||||
size="large"
|
||||
@pressEnter="submit"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-button type="primary" html-type="submit" size="large" block :loading="loading">登录</a-button>
|
||||
<a-button type="primary" html-type="submit" size="large" block :loading="loading" @click="submit">登录</a-button>
|
||||
</a-form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { CloudServerOutlined, KeyOutlined } from '@ant-design/icons-vue'
|
||||
import { api } from '../api'
|
||||
import { readError } from '../composables/useAdminConfigs'
|
||||
|
||||
interface ApiKeyState {
|
||||
configured: boolean
|
||||
masked: string
|
||||
source: 'database' | 'environment' | 'none'
|
||||
canManage: boolean
|
||||
}
|
||||
|
||||
interface LlmSettings {
|
||||
protocol: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
maxTokens: number
|
||||
temperature: number
|
||||
apiKey: ApiKeyState
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const savingKey = ref(false)
|
||||
const deletingKey = ref(false)
|
||||
const apiKeyInput = ref('')
|
||||
const testResult = ref<{ ok: boolean; detail: string } | null>(null)
|
||||
const keyState = ref<ApiKeyState>({ configured: false, masked: '', source: 'none', canManage: false })
|
||||
const form = reactive({
|
||||
protocol: 'responses',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o-mini',
|
||||
maxTokens: 1024,
|
||||
temperature: 0.7,
|
||||
})
|
||||
|
||||
const protocols = [
|
||||
{ value: 'responses', label: 'Responses', detail: 'OpenAI 新版接口,支持 Agent 工具调用' },
|
||||
{ value: 'chat_completions', label: 'Chat Completions', detail: '兼容 OpenAI 风格的聊天补全服务' },
|
||||
{ value: 'messages', label: 'Messages', detail: '兼容 Anthropic Claude Messages API' },
|
||||
]
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { apply(await api.llmSettings()) }
|
||||
catch (error: any) { message.error(readError(error, '模型配置加载失败')) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function apply(settings: LlmSettings) {
|
||||
form.protocol = settings.protocol || 'responses'
|
||||
form.baseUrl = settings.baseUrl || 'https://api.openai.com/v1'
|
||||
form.model = settings.model || 'gpt-4o-mini'
|
||||
form.maxTokens = settings.maxTokens ?? 1024
|
||||
form.temperature = settings.temperature ?? 0.7
|
||||
keyState.value = settings.apiKey
|
||||
}
|
||||
|
||||
async function saveSettings(showSuccess = true) {
|
||||
saving.value = true
|
||||
try {
|
||||
apply(await api.updateLlmSettings({ ...form }))
|
||||
if (showSuccess) message.success('模型参数已保存')
|
||||
} catch (error: any) {
|
||||
message.error(readError(error, '模型参数保存失败'))
|
||||
throw error
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function saveApiKey() {
|
||||
if (!apiKeyInput.value.trim()) {
|
||||
message.warning('请输入新的 API Key')
|
||||
return
|
||||
}
|
||||
savingKey.value = true
|
||||
try {
|
||||
apply(await api.updateLlmApiKey(apiKeyInput.value))
|
||||
apiKeyInput.value = ''
|
||||
message.success('API Key 已加密保存')
|
||||
} catch (error: any) {
|
||||
message.error(readError(error, 'API Key 保存失败'))
|
||||
} finally { savingKey.value = false }
|
||||
}
|
||||
|
||||
async function deleteApiKey() {
|
||||
deletingKey.value = true
|
||||
try {
|
||||
apply(await api.deleteLlmApiKey())
|
||||
message.success(keyState.value.source === 'environment'
|
||||
? '数据库密钥已删除,当前回退使用环境变量'
|
||||
: 'API Key 已删除')
|
||||
} catch (error: any) {
|
||||
message.error(readError(error, 'API Key 删除失败'))
|
||||
} finally { deletingKey.value = false }
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
await saveSettings(false)
|
||||
const result = await api.testLlm()
|
||||
testResult.value = { ok: result.ok, detail: result.detail || result.error || '' }
|
||||
result.ok ? message.success('LLM 连接正常') : message.error(result.error || '连接失败')
|
||||
} catch (error: any) {
|
||||
testResult.value = { ok: false, detail: readError(error, '连接失败') }
|
||||
} finally { testing.value = false }
|
||||
}
|
||||
|
||||
function sourceLabel(source: ApiKeyState['source']) {
|
||||
if (source === 'database') return '后台加密配置'
|
||||
if (source === 'environment') return '环境变量回退'
|
||||
return '未配置'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<h1>模型服务</h1>
|
||||
<p>配置 AI 服务连接、模型参数和访问密钥。测试连接会先保存当前模型参数。</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button :loading="testing" @click="testConnection">测试连接</a-button>
|
||||
<a-button type="primary" :loading="saving" @click="saveSettings()">保存参数</a-button>
|
||||
</a-space>
|
||||
</header>
|
||||
|
||||
<a-skeleton v-if="loading" active :paragraph="{ rows: 9 }" />
|
||||
<template v-else>
|
||||
<a-alert
|
||||
v-if="testResult"
|
||||
:type="testResult.ok ? 'success' : 'error'"
|
||||
:message="testResult.ok ? '连接成功' : '连接失败'"
|
||||
:description="testResult.detail"
|
||||
show-icon
|
||||
closable
|
||||
class="result-alert"
|
||||
@close="testResult = null" />
|
||||
|
||||
<section class="settings-panel" aria-labelledby="connection-title">
|
||||
<div class="section-heading">
|
||||
<CloudServerOutlined />
|
||||
<div><h2 id="connection-title">连接与生成参数</h2><p>这些参数用于通用对话;OCR 等识别任务继续使用各自的低温配置。</p></div>
|
||||
</div>
|
||||
<a-form layout="vertical" class="form-grid">
|
||||
<a-form-item label="API 协议" class="span-full">
|
||||
<a-radio-group v-model:value="form.protocol" class="protocol-grid">
|
||||
<label v-for="protocol in protocols" :key="protocol.value" class="protocol-option">
|
||||
<a-radio :value="protocol.value" />
|
||||
<span><strong>{{ protocol.label }}</strong><small>{{ protocol.detail }}</small></span>
|
||||
</label>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="API 地址" class="span-full" extra="填写服务根地址,系统会按协议自动追加请求路径。">
|
||||
<a-input v-model:value="form.baseUrl" placeholder="https://api.openai.com/v1" />
|
||||
</a-form-item>
|
||||
<a-form-item label="模型名称">
|
||||
<a-input v-model:value="form.model" placeholder="gpt-4o-mini" />
|
||||
</a-form-item>
|
||||
<a-form-item label="最大输出 Token" extra="单次通用回复的最大输出量,范围 64–4096。">
|
||||
<a-input-number v-model:value="form.maxTokens" :min="64" :max="4096" style="width:100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="温度" extra="0 更稳定,数值越高回复越灵活;默认 0.7。">
|
||||
<a-input-number v-model:value="form.temperature" :min="0" :max="2" :step="0.1" style="width:100%" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<section class="settings-panel key-panel" aria-labelledby="key-title">
|
||||
<div class="section-heading key-heading">
|
||||
<KeyOutlined />
|
||||
<div><h2 id="key-title">API Key</h2><p>密钥保存后只显示尾号,完整值不会通过后台接口返回。</p></div>
|
||||
<a-tag :color="keyState.configured ? 'green' : 'default'">{{ sourceLabel(keyState.source) }}</a-tag>
|
||||
</div>
|
||||
<div class="key-status">
|
||||
<span class="status-label">当前密钥</span>
|
||||
<strong>{{ keyState.configured ? keyState.masked : '尚未配置' }}</strong>
|
||||
</div>
|
||||
<template v-if="keyState.canManage">
|
||||
<div class="key-editor">
|
||||
<a-input-password
|
||||
v-model:value="apiKeyInput"
|
||||
autocomplete="new-password"
|
||||
placeholder="输入新的 API Key,保存后将替换当前密钥"
|
||||
@press-enter="saveApiKey" />
|
||||
<a-button type="primary" :loading="savingKey" @click="saveApiKey">保存密钥</a-button>
|
||||
<a-popconfirm
|
||||
v-if="keyState.source === 'database'"
|
||||
title="删除后台保存的密钥?"
|
||||
description="删除后将回退使用 LLM_API_KEY;若未配置环境变量,AI 服务会停用。"
|
||||
ok-text="删除"
|
||||
cancel-text="取消"
|
||||
@confirm="deleteApiKey">
|
||||
<a-button danger :loading="deletingKey">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
<p class="key-note">保存时由服务端自动加密,不需要额外配置加密密钥。</p>
|
||||
</template>
|
||||
<a-alert v-else type="info" message="只有超级管理员可以替换或删除 API Key。" show-icon />
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page { max-width: 1040px; margin: 0 auto; color: #191f26; }
|
||||
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
|
||||
.page-heading h1 { margin: 0 0 6px; font-size: 24px; line-height: 1.3; }
|
||||
.page-heading p, .section-heading p { margin: 0; color: #5e6772; line-height: 1.6; }
|
||||
.settings-panel { padding: 24px; border: 1px solid #eef0f3; border-radius: 14px; background: #fff; }
|
||||
.settings-panel + .settings-panel { margin-top: 18px; }
|
||||
.section-heading { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 22px; }
|
||||
.section-heading > :first-child { margin-top: 3px; color: #5b6bf5; font-size: 20px; }
|
||||
.section-heading h2 { margin: 0 0 4px; font-size: 17px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
|
||||
.span-full { grid-column: 1 / -1; }
|
||||
.protocol-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; width: 100%; }
|
||||
.protocol-option { display: flex; align-items: flex-start; gap: 4px; min-height: 82px; padding: 14px; border: 1px solid #e4e7eb; border-radius: 12px; cursor: pointer; transition: border-color .18s ease-out, background-color .18s ease-out; }
|
||||
.protocol-option:hover { border-color: #9ba5fa; background: #f8f9ff; }
|
||||
.protocol-option:has(.ant-radio-checked) { border-color: #5b6bf5; background: #f3f4ff; }
|
||||
.protocol-option strong, .protocol-option small { display: block; }
|
||||
.protocol-option small { margin-top: 4px; color: #5e6772; line-height: 1.45; }
|
||||
.key-heading { align-items: center; }
|
||||
.key-heading > div { flex: 1; }
|
||||
.key-status { display: flex; align-items: baseline; gap: 18px; padding: 14px 16px; margin-bottom: 16px; border-radius: 12px; background: #f6f7f9; }
|
||||
.status-label { color: #5e6772; }
|
||||
.key-status strong { font-variant-numeric: tabular-nums; letter-spacing: .04em; }
|
||||
.key-editor { display: grid; grid-template-columns: minmax(240px, 1fr) auto auto; gap: 10px; }
|
||||
.key-note { margin: 10px 0 0; color: #5e6772; font-size: 12px; }
|
||||
.result-alert { margin-bottom: 18px; }
|
||||
@media (max-width: 760px) {
|
||||
.page-heading { flex-direction: column; }
|
||||
.form-grid, .protocol-grid, .key-editor { grid-template-columns: 1fr; }
|
||||
.settings-panel { padding: 18px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { BgColorsOutlined, BookOutlined } from '@ant-design/icons-vue'
|
||||
import { useAdminConfigs } from '../composables/useAdminConfigs'
|
||||
|
||||
const keys = [
|
||||
'brand.app_name', 'brand.slogan', 'brand.logo_url',
|
||||
'system.default_ledger_name', 'system.max_ledgers_per_user',
|
||||
]
|
||||
const config = useAdminConfigs({
|
||||
'brand.app_name': '记之',
|
||||
'brand.slogan': '会聊天的记账本 · 让 AI 帮你管钱',
|
||||
'brand.logo_url': '',
|
||||
'system.default_ledger_name': '日常账本',
|
||||
'system.max_ledgers_per_user': '10',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<header class="page-heading">
|
||||
<div><h1>品牌与基础设置</h1><p>管理客户端品牌展示和新用户账本的默认规则。</p></div>
|
||||
<a-button type="primary" :loading="config.saving.value" @click="config.save(keys)">保存设置</a-button>
|
||||
</header>
|
||||
<a-skeleton v-if="config.loading.value" active :paragraph="{ rows: 7 }" />
|
||||
<section v-else class="settings-panel">
|
||||
<div class="section-heading"><BgColorsOutlined /><div><h2>品牌展示</h2><p>名称、标语和 Logo 会用于客户端的公共品牌位置。</p></div></div>
|
||||
<a-form layout="vertical" class="form-grid">
|
||||
<a-form-item label="App 名称">
|
||||
<a-input :value="config.value('brand.app_name')" @input="(e:any) => config.setValue('brand.app_name', e.target.value)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="App 标语">
|
||||
<a-input :value="config.value('brand.slogan')" @input="(e:any) => config.setValue('brand.slogan', e.target.value)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Logo URL" class="span-full" extra="请填写客户端可直接访问的 HTTPS 图片地址。">
|
||||
<a-input :value="config.value('brand.logo_url')" placeholder="https://..." @input="(e:any) => config.setValue('brand.logo_url', e.target.value)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider />
|
||||
<div class="section-heading"><BookOutlined /><div><h2>新用户账本</h2><p>仅影响之后创建的账号和账本,不会覆盖现有用户数据。</p></div></div>
|
||||
<a-form layout="vertical" class="form-grid">
|
||||
<a-form-item label="默认账本名">
|
||||
<a-input :value="config.value('system.default_ledger_name')" @input="(e:any) => config.setValue('system.default_ledger_name', e.target.value)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="每人最多账本数">
|
||||
<a-input-number :value="config.numberValue('system.max_ledgers_per_user')" :min="1" :max="50" style="width:100%" @change="(value: number | null) => config.setValue('system.max_ledgers_per_user', value)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page { max-width: 1040px; margin: 0 auto; color: #191f26; }
|
||||
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
|
||||
.page-heading h1 { margin: 0 0 6px; font-size: 24px; }
|
||||
.page-heading p, .section-heading p { margin: 0; color: #5e6772; line-height: 1.6; }
|
||||
.settings-panel { padding: 24px; border: 1px solid #eef0f3; border-radius: 14px; }
|
||||
.section-heading { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 20px; }
|
||||
.section-heading > :first-child { margin-top: 3px; color: #00a67d; font-size: 20px; }
|
||||
.section-heading h2 { margin: 0 0 4px; font-size: 17px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
|
||||
.span-full { grid-column: 1 / -1; }
|
||||
@media (max-width: 760px) { .page-heading { flex-direction: column; } .form-grid { grid-template-columns: 1fr; } .settings-panel { padding: 18px; } }
|
||||
</style>
|
||||
@@ -1,234 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
interface Config { id: number; key: string; value: string; version: number }
|
||||
|
||||
const configs = ref<Record<string, Config>>({})
|
||||
const loading = ref(true)
|
||||
const testing = ref(false)
|
||||
const testOk = ref<boolean | null>(null)
|
||||
const testMsg = ref('')
|
||||
|
||||
const llmKeys = ['llm.protocol','llm.base_url','llm.model','llm.max_tokens','llm.temperature']
|
||||
const limitKeys = ['limit.daily_ai_messages','limit.daily_ai_messages_per_user','limit.max_monthly_budget']
|
||||
const featureKeys = ['feature.ocr_enabled','feature.voice_enabled','feature.ai_auto_book','feature.sticker_enabled']
|
||||
const permissionKeys = ['permission.default.ai_enabled']
|
||||
const quotaDefaultKeys = ['quota.default_ai_chat_limit','quota.default_ai_chat_period']
|
||||
const systemKeys = ['system.default_ledger_name','system.max_ledgers_per_user','brand.app_name','brand.slogan','brand.logo_url']
|
||||
|
||||
const protocols = [
|
||||
{ value: 'chat_completions', label: 'Chat Completions', desc: 'OpenAI Chat Completions API。POST /v1/chat/completions', endpoint: '/chat/completions' },
|
||||
{ value: 'responses', label: 'Responses', desc: 'OpenAI Responses API(新版)。POST /v1/responses', endpoint: '/responses' },
|
||||
{ value: 'messages', label: 'Messages', desc: 'Anthropic Messages API。POST /v1/messages,用于 Claude 系列', endpoint: '/messages' },
|
||||
]
|
||||
|
||||
const meta: Record<string, { label: string; desc: string; type: string }> = {
|
||||
'llm.protocol': { label: 'API 协议', desc: '', type: 'protocol' },
|
||||
'llm.base_url': { label: 'API 地址', desc: '', type: 'url' }, 'llm.model': { label: '模型名称', desc: '', type: 'text' },
|
||||
'llm.max_tokens': { label: '最大输出 Token', desc: '单次请求上限', type: 'number' },
|
||||
'llm.temperature': { label: '温度 (Temperature)', desc: '0=确定 1=创意', type: 'number' },
|
||||
'limit.daily_ai_messages': { label: '全站每日 AI 消息上限', desc: '', type: 'number' },
|
||||
'limit.daily_ai_messages_per_user': { label: '每人每日 AI 消息上限', desc: '', type: 'number' },
|
||||
'limit.max_monthly_budget': { label: '最大月预算金额', desc: '', type: 'number' },
|
||||
'feature.ocr_enabled': { label: 'OCR 小票识别', desc: '拍照记账功能', type: 'switch' },
|
||||
'feature.voice_enabled': { label: '语音记账', desc: '语音输入转文字', type: 'switch' },
|
||||
'feature.ai_auto_book': { label: 'AI 自动入账', desc: 'AI 识别记账意图直接入账', type: 'switch' },
|
||||
'feature.sticker_enabled': { label: '表情包功能', desc: '聊天表情包面板 + AI 表情回复', type: 'switch' },
|
||||
'permission.default.ai_enabled': { label: '新用户默认启用 AI', desc: '仅影响保存后注册的新用户,现有用户权限不变', type: 'switch' },
|
||||
'quota.default_ai_chat_limit': { label: '新用户默认 AI 对话次数', desc: '0 表示不限次数', type: 'number' },
|
||||
'quota.default_ai_chat_period': { label: '新用户默认额度周期', desc: '按上海时区自然周期重置', type: 'period' },
|
||||
'system.default_ledger_name': { label: '新用户默认账本名', desc: '', type: 'text' },
|
||||
'system.max_ledgers_per_user': { label: '每人最多账本数', desc: '', type: 'number' },
|
||||
'brand.app_name': { label: 'App 名称', desc: '', type: 'text' },
|
||||
'brand.slogan': { label: 'App 标语', desc: '', type: 'text' },
|
||||
'brand.logo_url': { label: 'Logo URL', desc: '', type: 'url' },
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const list = await api.configs()
|
||||
const map: Record<string, Config> = {}
|
||||
for (const c of list) map[c.key] = c
|
||||
configs.value = map
|
||||
if (!map['llm.protocol']) setVal('llm.protocol', 'chat_completions')
|
||||
if (!map['quota.default_ai_chat_limit']) setVal('quota.default_ai_chat_limit', 50)
|
||||
if (!map['quota.default_ai_chat_period']) setVal('quota.default_ai_chat_period', 'day')
|
||||
selProtocol.value = getVal('llm.protocol') || 'chat_completions'
|
||||
} finally { loading.value = false }
|
||||
})
|
||||
|
||||
function getVal(key: string): any {
|
||||
const c = configs.value[key]
|
||||
if (!c) return ''
|
||||
const m = meta[key]
|
||||
if (m?.type === 'number') return Number(c.value) || 0
|
||||
if (m?.type === 'switch') return c.value === 'true'
|
||||
return c.value
|
||||
}
|
||||
|
||||
function setVal(key: string, val: any) {
|
||||
const str = typeof val === 'boolean' ? String(val) : String(val)
|
||||
if (configs.value[key]) configs.value[key].value = str
|
||||
else configs.value[key] = { id: 0, key, value: str, version: 0 }
|
||||
}
|
||||
|
||||
const selProtocol = ref('chat_completions')
|
||||
|
||||
async function saveSection(keys: string[]) {
|
||||
setVal('llm.protocol', selProtocol.value)
|
||||
const tasks = keys.filter(k => configs.value[k]).map(async k => {
|
||||
const cfg = configs.value[k]
|
||||
try {
|
||||
if (cfg.id > 0) {
|
||||
const r = await api.updateConfig(cfg.id, cfg.value)
|
||||
if (cfg) cfg.id = r.id
|
||||
} else {
|
||||
const r = await api.createConfig(cfg.key, cfg.value)
|
||||
if (cfg) cfg.id = r.id
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(k + ' 保存失败: ' + (e?.response?.data?.detail || e?.response?.data?.error || e.message))
|
||||
}
|
||||
})
|
||||
if (tasks.length === 0) { message.warning('没有可保存的配置'); return }
|
||||
await Promise.all(tasks)
|
||||
message.success('已保存 ' + tasks.length + ' 项配置')
|
||||
}
|
||||
|
||||
async function testLlm() {
|
||||
testing.value = true; testOk.value = null; testMsg.value = ''
|
||||
try {
|
||||
const r = await api.testLlm()
|
||||
testOk.value = r.ok
|
||||
testMsg.value = r.detail || r.error || ''
|
||||
if (r.ok) message.success('LLM 连接正常')
|
||||
else message.error(r.error || '连接失败')
|
||||
} catch (e: any) {
|
||||
testOk.value = false
|
||||
const data = e?.response?.data
|
||||
testMsg.value = data?.detail || data?.error || data?.message || e.message || '未知错误'
|
||||
message.error('连接失败')
|
||||
} finally { testing.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" style="text-align:center;padding:60px"><a-spin size="large" /></div>
|
||||
<template v-else>
|
||||
<h2 style="margin-bottom:18px">系统设置</h2>
|
||||
|
||||
<a-card title="LLM 大模型配置" size="small" style="margin-bottom:14px">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button size="small" @click="testLlm" :loading="testing">测试连接</a-button>
|
||||
<a-button size="small" type="primary" @click="saveSection(llmKeys)">保存</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template v-if="testOk !== null">
|
||||
<a-alert :type="testOk ? 'success' : 'error'" :message="testOk ? '连接成功' : '连接失败'" :description="testMsg" show-icon style="margin-bottom:14px" closable />
|
||||
</template>
|
||||
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item label="API 协议" :span="2">
|
||||
<a-radio-group v-model:value="selProtocol" style="width:100%">
|
||||
<a-row :gutter="[8,8]">
|
||||
<a-col v-for="p in protocols" :key="p.value" :span="8">
|
||||
<a-radio :value="p.value" style="display:block">
|
||||
<span style="font-weight:600">{{ p.label }}</span>
|
||||
<div style="font-size:11px;color:#999;margin-top:2px;white-space:normal">{{ p.desc }}</div>
|
||||
<div style="font-size:10px;color:#bbb;margin-top:2px;font-family:monospace">{{ p.endpoint }}</div>
|
||||
</a-radio>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-radio-group>
|
||||
</a-descriptions-item>
|
||||
|
||||
<a-descriptions-item label="API 地址" :span="2">
|
||||
<a-input :value="getVal('llm.base_url')" @change="(e:any)=>setVal('llm.base_url', e.target.value)" placeholder="https://api.openai.com/v1" />
|
||||
<div style="color:#999;font-size:11px;margin-top:2px">会自动拼上协议路径</div>
|
||||
</a-descriptions-item>
|
||||
|
||||
<a-descriptions-item label="API Key" :span="2">
|
||||
<a-alert type="info" message="API Key 仅通过服务器环境变量 LLM_API_KEY 配置,后台不会读取或显示完整密钥。" show-icon />
|
||||
</a-descriptions-item>
|
||||
|
||||
<a-descriptions-item label="模型名称" :span="2">
|
||||
<a-input :value="getVal('llm.model')" @change="(e:any)=>setVal('llm.model', e.target.value)" placeholder="gpt-4o-mini" />
|
||||
</a-descriptions-item>
|
||||
|
||||
<a-descriptions-item label="最大输出 Token" :span="1">
|
||||
<a-input-number :value="getVal('llm.max_tokens')" @change="(val:any)=>setVal('llm.max_tokens', val)" style="width:100%" :min="1" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="温度" :span="1">
|
||||
<a-input-number :value="getVal('llm.temperature')" @change="(val:any)=>setVal('llm.temperature', val)" style="width:100%" :min="0" :max="2" :step="0.1" />
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<a-card title="限额配置" size="small" style="margin-bottom:14px">
|
||||
<template #extra><a-button size="small" type="primary" @click="saveSection(limitKeys)">保存</a-button></template>
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item v-for="k in limitKeys" :key="k" :label="meta[k]?.label" :span="1">
|
||||
<a-input-number :value="getVal(k)" @change="(val:any)=>setVal(k, val)" style="width:100%" :min="0" />
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<a-card title="功能开关" size="small" style="margin-bottom:14px">
|
||||
<template #extra><a-button size="small" type="primary" @click="saveSection(featureKeys)">保存</a-button></template>
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item v-for="k in featureKeys" :key="k" :label="meta[k]?.label" :span="1">
|
||||
<a-switch :checked="getVal(k)" @change="(val:boolean)=>setVal(k, val)" />
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<a-card title="注册默认权限" size="small" style="margin-bottom:14px">
|
||||
<template #extra><a-button size="small" type="primary" @click="saveSection(permissionKeys)">保存</a-button></template>
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item v-for="k in permissionKeys" :key="k" :label="meta[k]?.label">
|
||||
<a-switch :checked="getVal(k)" @change="(val:boolean)=>setVal(k, val)" />
|
||||
<span style="margin-left:10px;color:#8c8c8c">{{ meta[k]?.desc }}</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<a-card title="新用户 AI 对话额度" size="small" style="margin-bottom:14px">
|
||||
<template #extra><a-button size="small" type="primary" @click="saveSection(quotaDefaultKeys)">保存</a-button></template>
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item label="默认次数">
|
||||
<a-input-number
|
||||
:value="getVal('quota.default_ai_chat_limit')"
|
||||
@change="(val:any)=>setVal('quota.default_ai_chat_limit', val)"
|
||||
style="width:100%"
|
||||
:min="0"
|
||||
:max="1000000" />
|
||||
<div style="color:#999;font-size:11px;margin-top:3px">0 表示不限次数</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="重置周期">
|
||||
<a-select
|
||||
:value="getVal('quota.default_ai_chat_period') || 'day'"
|
||||
@change="(val:string)=>setVal('quota.default_ai_chat_period', val)"
|
||||
style="width:100%">
|
||||
<a-select-option value="day">每天</a-select-option>
|
||||
<a-select-option value="week">每周(周一开始)</a-select-option>
|
||||
<a-select-option value="month">每月</a-select-option>
|
||||
</a-select>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<a-card title="系统 & 品牌" size="small" style="margin-bottom:14px">
|
||||
<template #extra><a-button size="small" type="primary" @click="saveSection(systemKeys)">保存</a-button></template>
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item v-for="k in systemKeys" :key="k" :label="meta[k]?.label" :span="k==='brand.slogan'||k==='brand.logo_url'?2:1">
|
||||
<a-input-number v-if="k==='system.max_ledgers_per_user'" :value="getVal(k)" @change="(val:any)=>setVal(k, val)" style="width:100%" :min="1" :max="50" />
|
||||
<a-input v-else :value="getVal(k)" @change="(e:any)=>setVal(k, e.target.value)" />
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</template>
|
||||
</template>
|
||||
@@ -3,8 +3,9 @@ using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using System.Text.Json;
|
||||
using MiaoJiZhang.Api.Controllers;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
@@ -128,9 +129,33 @@ public sealed class ApiFixture : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class ApiIntegrationTests(ApiFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public sealed class ApiIntegrationTests(ApiFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task PublicRuntimeDefaults_AreAvailableForProductionFlows()
|
||||
{
|
||||
using var publicClient = fixture.Factory.CreateClient();
|
||||
var avatars = await publicClient.GetFromJsonAsync<JsonElement>(
|
||||
"/api/public/avatars");
|
||||
var personas = await publicClient.GetFromJsonAsync<JsonElement>(
|
||||
"/api/public/personas");
|
||||
|
||||
Assert.Contains(avatars.EnumerateArray(), item =>
|
||||
item.GetProperty("key").GetString() == "cat");
|
||||
Assert.Contains(personas.EnumerateArray(), item =>
|
||||
item.GetProperty("key").GetString() == "sassy_cat");
|
||||
|
||||
using var user = await fixture.RegisterAsync("runtime_defaults_user");
|
||||
foreach (var type in new[] { "expense", "income" })
|
||||
{
|
||||
var categories = await user.GetFromJsonAsync<JsonElement>(
|
||||
$"/api/categories?type={type}");
|
||||
Assert.Contains(categories.EnumerateArray(), item =>
|
||||
item.GetProperty("name").GetString() == "其他");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataIsolation_TimeZone_Recycle_Budget_AndExport_WorkTogether()
|
||||
{
|
||||
using var owner = await fixture.RegisterAsync("owner_account");
|
||||
@@ -663,8 +688,12 @@ public sealed class ApiIntegrationTests(ApiFixture fixture)
|
||||
using var superAdmin = await fixture.AdminAsync();
|
||||
var suffix = Guid.NewGuid().ToString("N")[..10];
|
||||
var username = $"viewer_{suffix}";
|
||||
const string initialPassword = "viewer-initial-password-123";
|
||||
const string permanentPassword = "viewer-permanent-password-456";
|
||||
const string initialPassword = "init06";
|
||||
const string permanentPassword = "new006";
|
||||
var shortPasswordResponse = await superAdmin.PostAsJsonAsync(
|
||||
"/api/admin/security/accounts",
|
||||
new { username = $"{username}_short", password = "12345", role = "viewer" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, shortPasswordResponse.StatusCode);
|
||||
var createdResponse = await superAdmin.PostAsJsonAsync(
|
||||
"/api/admin/security/accounts",
|
||||
new { username, password = initialPassword, role = "viewer" });
|
||||
@@ -859,6 +888,29 @@ public sealed class RecognitionBatchActionParserTests
|
||||
Assert.Equal(1, action.Confidence);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RecognitionAmountUpdateEvidenceTests
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string?> EvidenceOwners =
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["evidence-a"] = "candidate-a",
|
||||
["evidence-b"] = "candidate-b"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void AmountUpdateRequiresLinkedEvidenceAndHighConfidence()
|
||||
{
|
||||
Assert.False(ParseController.CanApplyAmountUpdate(
|
||||
"candidate-a", null, 0.99, EvidenceOwners));
|
||||
Assert.False(ParseController.CanApplyAmountUpdate(
|
||||
"candidate-a", "evidence-a", 0.89, EvidenceOwners));
|
||||
Assert.False(ParseController.CanApplyAmountUpdate(
|
||||
"candidate-a", "evidence-b", 0.99, EvidenceOwners));
|
||||
Assert.True(ParseController.CanApplyAmountUpdate(
|
||||
"candidate-a", "evidence-a", 0.9, EvidenceOwners));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class BudgetRecommendationValidationTests
|
||||
{
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Security.Cryptography;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MiaoJiZhang.Api.Tests;
|
||||
|
||||
public sealed class LlmSecretProtectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Protect_RoundTripsWithoutEmbeddingPlaintext()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Jwt:Secret"] = Convert.ToBase64String(
|
||||
RandomNumberGenerator.GetBytes(48)),
|
||||
})
|
||||
.Build();
|
||||
var protector = new LlmSecretProtector(configuration);
|
||||
|
||||
var encrypted = protector.Protect("sk-test-secret-1234");
|
||||
|
||||
Assert.DoesNotContain("sk-test-secret-1234", encrypted);
|
||||
Assert.Equal("sk-test-secret-1234", protector.Unprotect(encrypted));
|
||||
Assert.Equal("••••1234", LlmSecretProtector.Mask("sk-test-secret-1234"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Protect_RejectsMissingServerSecret()
|
||||
{
|
||||
var protector = new LlmSecretProtector(new ConfigurationBuilder().Build());
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(
|
||||
() => protector.Protect("sk-test"));
|
||||
|
||||
Assert.Contains("JWT 密钥", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_UseStableGenerationParameters()
|
||||
{
|
||||
Assert.Equal("1024", AppConfigDefaults.Values["llm.max_tokens"]);
|
||||
Assert.Equal("0.7", AppConfigDefaults.Values["llm.temperature"]);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,8 @@ public sealed class AdminAccountsController(AppDbContext db) : ControllerBase
|
||||
public async Task<IActionResult> Create(CreateAdminUserRequest request, CancellationToken ct)
|
||||
{
|
||||
var username = request.Username.Trim();
|
||||
if (username.Length is < 3 or > 64 || request.Password.Length is < 12 or > 128 ||
|
||||
if (username.Length is < 3 or > 64 ||
|
||||
request.Password.Length is < AdminPasswordPolicy.MinLength or > AdminPasswordPolicy.MaxLength ||
|
||||
!AdminRoles.All.Contains(request.Role))
|
||||
return BadRequest(new ApiError("ADMIN_ACCOUNT_INVALID", "管理员账号、密码或角色无效"));
|
||||
if (await db.AdminUsers.AnyAsync(item => item.Username == username, ct))
|
||||
@@ -90,8 +91,8 @@ public sealed class AdminAccountsController(AppDbContext db) : ControllerBase
|
||||
ResetAdminPasswordRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request.Password.Length is < 12 or > 128)
|
||||
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "密码长度必须为 12 到 128 位"));
|
||||
if (request.Password.Length is < AdminPasswordPolicy.MinLength or > AdminPasswordPolicy.MaxLength)
|
||||
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "密码长度必须为 6 到 128 位"));
|
||||
var user = await db.AdminUsers.FindAsync([id], ct);
|
||||
if (user is null) return NotFound();
|
||||
user.PasswordHash = AdminSessionService.HashPassword(request.Password);
|
||||
|
||||
@@ -53,8 +53,9 @@ public sealed class AdminAuthController(
|
||||
AdminChangePasswordRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request.NewPassword.Length < 12 || request.NewPassword.Length > 128)
|
||||
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "新密码长度必须为 12 到 128 位"));
|
||||
if (request.NewPassword.Length < AdminPasswordPolicy.MinLength ||
|
||||
request.NewPassword.Length > AdminPasswordPolicy.MaxLength)
|
||||
return BadRequest(new ApiError("ADMIN_PASSWORD_INVALID", "新密码长度必须为 6 到 128 位"));
|
||||
var principal = AdminRequestContext.Principal(HttpContext)!;
|
||||
var user = await db.AdminUsers.FirstAsync(item => item.Id == principal.UserId, ct);
|
||||
if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash))
|
||||
|
||||
@@ -8,10 +8,13 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AdminAuth]
|
||||
[Route("api/admin")]
|
||||
public class AdminController(AppDbContext db) : ControllerBase
|
||||
[ApiController]
|
||||
[AdminAuth]
|
||||
[Route("api/admin")]
|
||||
public class AdminController(
|
||||
AppDbContext db,
|
||||
LlmSecretProtector llmSecrets,
|
||||
OpenAiVisionClient llmClient) : ControllerBase
|
||||
{
|
||||
[HttpGet("dashboard")] public async Task<IActionResult> Dashboard() { var tu = await db.Users.CountAsync(); var ta = await db.Users.CountAsync(u => u.LastLoginAt.HasValue && u.LastLoginAt.Value >= ChinaClock.ToUtc(ChinaClock.Now.Date) && u.LastLoginAt.Value < ChinaClock.ToUtc(ChinaClock.Now.Date.AddDays(1))); var tt = await db.Transactions.IgnoreQueryFilters().CountAsync(); var ai = await db.Transactions.IgnoreQueryFilters().CountAsync(t => TransactionSourceRules.AiAssisted.Contains(t.Source)); var at = await db.Transactions.IgnoreQueryFilters().Where(t => TransactionSourceRules.AiAssisted.Contains(t.Source)).CountAsync(); var ud = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.IsDeleted && TransactionSourceRules.AiAssisted.Contains(t.Source)); return Ok(new { users = new { total = tu, activeToday = ta }, transactions = new { total = tt, aiBooked = ai }, aiAccuracy = at == 0 ? 0 : Math.Round((1.0 - (double)ud / at) * 100, 1), undoRate = at == 0 ? 0 : Math.Round((double)ud / at * 100, 1), aiMessages = await db.ChatMessages.CountAsync(m => m.Role == ChatRole.Assistant) }); }
|
||||
|
||||
@@ -38,9 +41,11 @@ public class AdminController(AppDbContext db) : ControllerBase
|
||||
return BadRequest(new { error = "secret_env_only", detail = "密钥只能通过服务端环境变量修改" });
|
||||
cfg.Value = req.Value;
|
||||
cfg.Version++;
|
||||
cfg.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||
cfg.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
if (cfg.Key.StartsWith("llm.", StringComparison.OrdinalIgnoreCase))
|
||||
llmClient.InvalidateConfiguration();
|
||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||
}
|
||||
[HttpPost("configs")]
|
||||
public async Task<IActionResult> CreateConfig([FromBody] CreateConfigRequest req)
|
||||
@@ -51,9 +56,11 @@ public class AdminController(AppDbContext db) : ControllerBase
|
||||
if (await db.AppConfigs.AnyAsync(c => c.Key == req.Key))
|
||||
return Conflict(new { error = "key_exists", detail = "该配置 Key 已存在,请用 PUT 更新" });
|
||||
var cfg = new AppConfig { Key = req.Key.Trim(), Value = req.Value ?? "", Version = 1, UpdatedAt = DateTime.UtcNow };
|
||||
db.AppConfigs.Add(cfg);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||
db.AppConfigs.Add(cfg);
|
||||
await db.SaveChangesAsync();
|
||||
if (cfg.Key.StartsWith("llm.", StringComparison.OrdinalIgnoreCase))
|
||||
llmClient.InvalidateConfiguration();
|
||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||
}
|
||||
|
||||
[HttpGet("personas")] public async Task<IActionResult> ListPersonas() => Ok(await db.AiPersonas.OrderBy(p => p.Key).ToListAsync());
|
||||
@@ -71,9 +78,150 @@ public class AdminController(AppDbContext db) : ControllerBase
|
||||
[HttpPut("stickers/{id:long}")] public async Task<IActionResult> UpdateSticker(long id, [FromBody] UpsertStickerRequest req) { var s = await db.Stickers.FindAsync(id); if (s is null) return NotFound(); s.Key = req.Key; s.Label = req.Label; s.GroupKey = req.GroupKey; s.TriggerTags = req.TriggerTags; s.ImageUrl = req.ImageUrl; s.IsEnabled = req.IsEnabled; await db.SaveChangesAsync(); return Ok(s); }
|
||||
[HttpDelete("stickers/{id:long}")] public async Task<IActionResult> DeleteSticker(long id) { var s = await db.Stickers.FindAsync(id); if (s is null) return NotFound(); db.Stickers.Remove(s); await db.SaveChangesAsync(); return NoContent(); }
|
||||
|
||||
[HttpPost("llm/test")] public async Task<IActionResult> TestLlm([FromServices] ILlmClient llm) { var (ok, error) = await llm.TestConnectionAsync(); return Ok(new { ok, error = ok ? (string?)null : error, detail = ok ? "LLM 连接正常" : error }); }
|
||||
[HttpGet("llm/settings")]
|
||||
public async Task<IActionResult> GetLlmSettings()
|
||||
{
|
||||
var values = await db.AppConfigs
|
||||
.Where(config => config.Key.StartsWith("llm."))
|
||||
.ToDictionaryAsync(config => config.Key, config => config.Value);
|
||||
string Read(string key) => values.GetValueOrDefault(
|
||||
key,
|
||||
AppConfigDefaults.Values[key]);
|
||||
|
||||
var encrypted = values.GetValueOrDefault(LlmSecretProtector.ConfigKey);
|
||||
var source = "none";
|
||||
var masked = "";
|
||||
if (llmSecrets.TryUnprotect(encrypted, out var databaseKey))
|
||||
{
|
||||
source = "database";
|
||||
masked = LlmSecretProtector.Mask(databaseKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
var environmentKey = Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(environmentKey))
|
||||
{
|
||||
source = "environment";
|
||||
masked = LlmSecretProtector.Mask(environmentKey);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
protocol = Read("llm.protocol"),
|
||||
baseUrl = Read("llm.base_url"),
|
||||
model = Read("llm.model"),
|
||||
maxTokens = int.TryParse(Read("llm.max_tokens"), out var maxTokens)
|
||||
? Math.Clamp(maxTokens, 64, 4096) : 1024,
|
||||
temperature = double.TryParse(
|
||||
Read("llm.temperature"),
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var temperature)
|
||||
? Math.Clamp(temperature, 0, 2) : 0.7,
|
||||
apiKey = new
|
||||
{
|
||||
configured = source != "none",
|
||||
masked,
|
||||
source,
|
||||
canManage = AdminRequestContext.Principal(HttpContext)?.Role ==
|
||||
AdminRoles.SuperAdmin,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("llm/settings")]
|
||||
public async Task<IActionResult> UpdateLlmSettings(
|
||||
[FromBody] UpdateLlmSettingsRequest request)
|
||||
{
|
||||
var protocol = request.Protocol.Trim().ToLowerInvariant();
|
||||
if (protocol is not ("chat_completions" or "responses" or "messages"))
|
||||
return BadRequest(new { error = "protocol_invalid", detail = "API 协议不受支持" });
|
||||
if (!Uri.TryCreate(request.BaseUrl, UriKind.Absolute, out var baseUri) ||
|
||||
baseUri.Scheme is not ("http" or "https"))
|
||||
return BadRequest(new { error = "base_url_invalid", detail = "API 地址必须是有效的 HTTP 或 HTTPS 地址" });
|
||||
if (string.IsNullOrWhiteSpace(request.Model))
|
||||
return BadRequest(new { error = "model_required", detail = "模型名称不能为空" });
|
||||
if (request.MaxTokens is < 64 or > 4096)
|
||||
return BadRequest(new { error = "max_tokens_invalid", detail = "最大输出 Token 必须在 64 到 4096 之间" });
|
||||
if (request.Temperature is < 0 or > 2)
|
||||
return BadRequest(new { error = "temperature_invalid", detail = "温度必须在 0 到 2 之间" });
|
||||
|
||||
var values = new Dictionary<string, string>
|
||||
{
|
||||
["llm.protocol"] = protocol,
|
||||
["llm.base_url"] = request.BaseUrl.Trim().TrimEnd('/'),
|
||||
["llm.model"] = request.Model.Trim(),
|
||||
["llm.max_tokens"] = request.MaxTokens.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
["llm.temperature"] = request.Temperature.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
};
|
||||
await UpsertConfigsAsync(values);
|
||||
llmClient.InvalidateConfiguration();
|
||||
return await GetLlmSettings();
|
||||
}
|
||||
|
||||
[HttpPut("llm/api-key")]
|
||||
[AdminAuth(AdminRoles.SuperAdmin)]
|
||||
public async Task<IActionResult> UpdateLlmApiKey([FromBody] UpdateLlmApiKeyRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ApiKey))
|
||||
return BadRequest(new { error = "api_key_required", detail = "API Key 不能为空" });
|
||||
if (request.ApiKey.Trim().Length > 8192)
|
||||
return BadRequest(new { error = "api_key_too_long", detail = "API Key 长度异常" });
|
||||
|
||||
string protectedValue;
|
||||
try { protectedValue = llmSecrets.Protect(request.ApiKey); }
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
return Problem(
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "密钥加密尚未配置",
|
||||
detail: exception.Message);
|
||||
}
|
||||
await UpsertConfigsAsync(new Dictionary<string, string>
|
||||
{
|
||||
[LlmSecretProtector.ConfigKey] = protectedValue,
|
||||
});
|
||||
llmClient.InvalidateConfiguration();
|
||||
return await GetLlmSettings();
|
||||
}
|
||||
|
||||
[HttpDelete("llm/api-key")]
|
||||
[AdminAuth(AdminRoles.SuperAdmin)]
|
||||
public async Task<IActionResult> DeleteLlmApiKey()
|
||||
{
|
||||
var config = await db.AppConfigs.FirstOrDefaultAsync(
|
||||
item => item.Key == LlmSecretProtector.ConfigKey);
|
||||
if (config is not null)
|
||||
{
|
||||
db.AppConfigs.Remove(config);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
llmClient.InvalidateConfiguration();
|
||||
return await GetLlmSettings();
|
||||
}
|
||||
|
||||
[HttpPost("llm/test")]
|
||||
public async Task<IActionResult> TestLlm()
|
||||
{
|
||||
llmClient.InvalidateConfiguration();
|
||||
var (ok, error) = await llmClient.TestConnectionAsync();
|
||||
return Ok(new
|
||||
{
|
||||
ok,
|
||||
error = ok ? (string?)null : error,
|
||||
detail = ok ? "LLM 连接正常" : error,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("configs/init")] public async Task<IActionResult> InitConfigs() { var now = DateTime.UtcNow; var defaults = new Dictionary<string, string> { ["brand.app_name"] = "记之", ["brand.slogan"] = "会聊天的记账本 · 让 AI 帮你管钱", ["brand.logo_url"] = "", ["llm.protocol"] = "responses", ["llm.base_url"] = "https://api.openai.com/v1", ["llm.model"] = "gpt-4o-mini", ["llm.max_tokens"] = "1024", ["llm.temperature"] = "0.8", ["limit.daily_ai_messages"] = "200", ["limit.daily_ai_messages_per_user"] = "50", ["limit.max_monthly_budget"] = "99999999", ["feature.ocr_enabled"] = "true", ["feature.voice_enabled"] = "true", ["feature.ai_auto_book"] = "true", ["feature.sticker_enabled"] = "true", ["system.default_ledger_name"] = "日常账本", ["system.max_ledgers_per_user"] = "10", ["feature.screenshot_bookkeeping_enabled"] = "true", ["permission.default.ai_enabled"] = "true", ["quota.default_ai_chat_limit"] = "50", ["quota.default_ai_chat_period"] = "day" }; var existing = await db.AppConfigs.Select(c => c.Key).ToListAsync(); var added = 0; foreach (var (key, value) in defaults) { if (!existing.Contains(key)) { db.AppConfigs.Add(new AppConfig { Key = key, Value = value, Version = 1, UpdatedAt = now }); added++; } } if (added > 0) await db.SaveChangesAsync(); return Ok(new { added, total = defaults.Count }); }
|
||||
[HttpPost("configs/init")]
|
||||
public async Task<IActionResult> InitConfigs()
|
||||
{
|
||||
var added = await AppConfigDefaults.EnsureAsync(db);
|
||||
return Ok(new { added, total = AppConfigDefaults.Values.Count });
|
||||
}
|
||||
|
||||
[HttpGet("categories")] public async Task<IActionResult> ListSystemCategories() => Ok(await db.Categories.Where(c => c.UserId == null && !c.IsDeleted).OrderBy(c => c.Type).ThenBy(c => c.SortOrder).ToListAsync());
|
||||
[HttpPost("categories")]
|
||||
@@ -264,17 +412,53 @@ public class AdminController(AppDbContext db) : ControllerBase
|
||||
|
||||
[HttpGet("users/{id:long}/stats")] public async Task<IActionResult> UserStats(long id) { var u = await db.Users.FindAsync(id); if (u is null) return NotFound(); var txCount = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id); var aiCount = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id && TransactionSourceRules.AiAssisted.Contains(t.Source)); var undone = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id && t.IsDeleted && TransactionSourceRules.AiAssisted.Contains(t.Source)); return Ok(new { totalTransactions = txCount, aiBooked = aiCount, aiAccuracy = aiCount == 0 ? 0 : Math.Round((1.0 - (double)undone / aiCount) * 100, 1) }); }
|
||||
|
||||
private static bool IsSecret(string key) =>
|
||||
key.Equals("llm.api_key", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("secret", StringComparison.OrdinalIgnoreCase) ||
|
||||
private static bool IsSecret(string key) =>
|
||||
key.StartsWith("llm.api_key", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("secret", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("password", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.EndsWith("token", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
key.EndsWith("token", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task UpsertConfigsAsync(IReadOnlyDictionary<string, string> values)
|
||||
{
|
||||
var keys = values.Keys.ToArray();
|
||||
var existing = await db.AppConfigs
|
||||
.Where(config => keys.Contains(config.Key))
|
||||
.ToDictionaryAsync(config => config.Key);
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var (key, value) in values)
|
||||
{
|
||||
if (existing.TryGetValue(key, out var config))
|
||||
{
|
||||
config.Value = value;
|
||||
config.Version++;
|
||||
config.UpdatedAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
db.AppConfigs.Add(new AppConfig
|
||||
{
|
||||
Key = key,
|
||||
Value = value,
|
||||
Version = 1,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
}
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateConfigRequest(string Value);
|
||||
public record CreateConfigRequest(string Key, string Value);
|
||||
public record UpsertPersonaRequest(string Key, string Name, string Description, string SampleLine, string PromptTemplate, bool IsEnabled = true);
|
||||
public record UpsertAvatarRequest(string Key, string Name, string SpeechTic, string? ImageUrl, bool IsEnabled = true);
|
||||
public record UpsertStickerRequest(string Key, string Label, string GroupKey, string? TriggerTags, string? ImageUrl, bool IsEnabled = true);
|
||||
public record UpsertCategoryRequest(string Name, string IconKey, string Type);
|
||||
public record UpsertCategoryRequest(string Name, string IconKey, string Type);
|
||||
public record UpdateLlmSettingsRequest(
|
||||
string Protocol,
|
||||
string BaseUrl,
|
||||
string Model,
|
||||
int MaxTokens,
|
||||
double Temperature);
|
||||
public record UpdateLlmApiKeyRequest(string ApiKey);
|
||||
|
||||
|
||||
@@ -364,6 +364,9 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
||||
{
|
||||
var candidateIds = manifest.Candidates.Select(item => item.CandidateId).ToHashSet();
|
||||
var evidenceById = manifest.Evidence.ToDictionary(item => item.EvidenceId);
|
||||
var evidenceOwners = evidenceById.ToDictionary(
|
||||
entry => entry.Key,
|
||||
entry => entry.Value.CandidateId);
|
||||
var selected = modelActions
|
||||
.Where(action => action.CandidateId != null && candidateIds.Contains(action.CandidateId))
|
||||
.GroupBy(action => action.CandidateId!)
|
||||
@@ -401,7 +404,19 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
||||
transferDirection = candidate.TransferDirection;
|
||||
reason = "转账方向不明确,已保留本地结果";
|
||||
}
|
||||
var amount = model?.Amount is > 0 ? model.Amount.Value : candidate.Amount;
|
||||
var proposedAmount = model?.Amount is > 0 ? model.Amount.Value : candidate.Amount;
|
||||
var amountChanged = proposedAmount != candidate.Amount;
|
||||
var amountUpdateAllowed = !amountChanged ||
|
||||
model?.Action == "update" && CanApplyAmountUpdate(
|
||||
candidate.CandidateId,
|
||||
model.EvidenceId,
|
||||
model.Confidence,
|
||||
evidenceOwners);
|
||||
var amount = amountUpdateAllowed ? proposedAmount : candidate.Amount;
|
||||
if (amountChanged && !amountUpdateAllowed)
|
||||
{
|
||||
reason = "金额修改证据不足,已保留本地金额";
|
||||
}
|
||||
var category = FindCategory(
|
||||
categories,
|
||||
type == "income" || transferDirection == "in"
|
||||
@@ -464,6 +479,18 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
||||
}
|
||||
return result.Take(20).ToList();
|
||||
}
|
||||
|
||||
internal static bool CanApplyAmountUpdate(
|
||||
string candidateId,
|
||||
string? evidenceId,
|
||||
double confidence,
|
||||
IReadOnlyDictionary<string, string?> evidenceOwners)
|
||||
{
|
||||
return confidence >= 0.9 &&
|
||||
!string.IsNullOrWhiteSpace(evidenceId) &&
|
||||
evidenceOwners.TryGetValue(evidenceId, out var owner) &&
|
||||
owner == candidateId;
|
||||
}
|
||||
|
||||
private async Task<bool> FeatureEnabled(string key)
|
||||
{
|
||||
|
||||
@@ -63,6 +63,7 @@ builder.Services.AddScoped<AiChatQuotaService>();
|
||||
builder.Services.AddScoped<BudgetPushService>();
|
||||
builder.Services.AddScoped<AdminSessionService>();
|
||||
builder.Services.AddScoped<AdminBootstrapService>();
|
||||
builder.Services.AddSingleton<LlmSecretProtector>();
|
||||
builder.Services.AddSingleton<PushTokenProtector>();
|
||||
builder.Services.AddScoped<AiPermissionFilter>();
|
||||
builder.Services.AddHttpClient("LlmClient");
|
||||
@@ -165,10 +166,14 @@ using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
await AppConfigDefaults.EnsureAsync(db);
|
||||
await scope.ServiceProvider.GetRequiredService<AdminBootstrapService>().EnsureAsync();
|
||||
if (app.Environment.IsDevelopment())
|
||||
await DbSeeder.SeedAsync(db);
|
||||
}
|
||||
// These records are runtime defaults, not development fixtures. Production
|
||||
// databases also need them for onboarding, category fallback and stickers.
|
||||
// DbSeeder only inserts into an empty catalog, so existing admin-managed
|
||||
// records are preserved.
|
||||
await DbSeeder.SeedAsync(db);
|
||||
}
|
||||
if (app.Environment.IsDevelopment())
|
||||
app.MapOpenApi();
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@ public sealed class AdminBootstrapService(
|
||||
var username = configuration["Admin:BootstrapUsername"]?.Trim();
|
||||
var password = configuration["Admin:BootstrapPassword"];
|
||||
if (string.IsNullOrWhiteSpace(username) || username.Length is < 3 or > 64 ||
|
||||
string.IsNullOrWhiteSpace(password) || password.Length < 12)
|
||||
string.IsNullOrWhiteSpace(password) || password.Length < AdminPasswordPolicy.MinLength)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"首次启动必须通过 Admin__BootstrapUsername 和 Admin__BootstrapPassword 配置管理员,密码至少 12 位");
|
||||
"首次启动必须通过 Admin__BootstrapUsername 和 Admin__BootstrapPassword 配置管理员,密码至少 6 位");
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public static class AdminPasswordPolicy
|
||||
{
|
||||
public const int MinLength = 6;
|
||||
public const int MaxLength = 128;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public static class AppConfigDefaults
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, string> Values =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["brand.app_name"] = "记之",
|
||||
["brand.slogan"] = "会聊天的记账本 · 让 AI 帮你管钱",
|
||||
["brand.logo_url"] = "",
|
||||
["llm.protocol"] = "responses",
|
||||
["llm.base_url"] = "https://api.openai.com/v1",
|
||||
["llm.model"] = "gpt-4o-mini",
|
||||
["llm.max_tokens"] = "1024",
|
||||
["llm.temperature"] = "0.7",
|
||||
["limit.daily_ai_messages"] = "200",
|
||||
["limit.daily_ai_messages_per_user"] = "50",
|
||||
["limit.max_monthly_budget"] = "99999999",
|
||||
["feature.ocr_enabled"] = "true",
|
||||
["feature.voice_enabled"] = "true",
|
||||
["feature.ai_auto_book"] = "true",
|
||||
["feature.sticker_enabled"] = "true",
|
||||
["feature.screenshot_bookkeeping_enabled"] = "true",
|
||||
["permission.default.ai_enabled"] = "true",
|
||||
["quota.default_ai_chat_limit"] = "50",
|
||||
["quota.default_ai_chat_period"] = "day",
|
||||
["system.default_ledger_name"] = "日常账本",
|
||||
["system.max_ledgers_per_user"] = "10",
|
||||
};
|
||||
|
||||
public static async Task<int> EnsureAsync(
|
||||
AppDbContext db,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var existing = await db.AppConfigs
|
||||
.Select(config => config.Key)
|
||||
.ToListAsync(ct);
|
||||
var keys = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var now = DateTime.UtcNow;
|
||||
var added = 0;
|
||||
foreach (var (key, value) in Values)
|
||||
{
|
||||
if (keys.Contains(key)) continue;
|
||||
db.AppConfigs.Add(new AppConfig
|
||||
{
|
||||
Key = key,
|
||||
Value = value,
|
||||
Version = 1,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
added++;
|
||||
}
|
||||
if (added > 0) await db.SaveChangesAsync(ct);
|
||||
return added;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed class LlmSecretProtector(IConfiguration configuration)
|
||||
{
|
||||
public const string ConfigKey = "llm.api_key_encrypted";
|
||||
private const string Prefix = "v1";
|
||||
|
||||
public string Protect(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new ArgumentException("API Key 不能为空", nameof(value));
|
||||
var key = ReadEncryptionKey();
|
||||
var nonce = RandomNumberGenerator.GetBytes(12);
|
||||
var plaintext = Encoding.UTF8.GetBytes(value.Trim());
|
||||
var ciphertext = new byte[plaintext.Length];
|
||||
var tag = new byte[16];
|
||||
using var aes = new AesGcm(key, tag.Length);
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||
return string.Join(':', Prefix,
|
||||
Convert.ToBase64String(nonce),
|
||||
Convert.ToBase64String(ciphertext),
|
||||
Convert.ToBase64String(tag));
|
||||
}
|
||||
|
||||
public string Unprotect(string protectedValue)
|
||||
{
|
||||
var parts = protectedValue.Split(':');
|
||||
if (parts.Length != 4 || parts[0] != Prefix)
|
||||
throw new CryptographicException("不支持的密钥密文格式");
|
||||
var nonce = Convert.FromBase64String(parts[1]);
|
||||
var ciphertext = Convert.FromBase64String(parts[2]);
|
||||
var tag = Convert.FromBase64String(parts[3]);
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
using var aes = new AesGcm(ReadEncryptionKey(), tag.Length);
|
||||
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||
return Encoding.UTF8.GetString(plaintext);
|
||||
}
|
||||
|
||||
public bool TryUnprotect(string? protectedValue, out string value)
|
||||
{
|
||||
value = "";
|
||||
if (string.IsNullOrWhiteSpace(protectedValue)) return false;
|
||||
try
|
||||
{
|
||||
value = Unprotect(protectedValue);
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is ArgumentException or FormatException or
|
||||
CryptographicException or InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string Mask(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "";
|
||||
var suffixLength = Math.Min(4, value.Length);
|
||||
return $"••••{value[^suffixLength..]}";
|
||||
}
|
||||
|
||||
private byte[] ReadEncryptionKey()
|
||||
{
|
||||
var jwtSecret = configuration["Jwt:Secret"];
|
||||
if (string.IsNullOrWhiteSpace(jwtSecret) || jwtSecret.Length < 32)
|
||||
throw new InvalidOperationException("服务端 JWT 密钥配置无效,无法保护 API Key");
|
||||
return HMACSHA256.HashData(
|
||||
Encoding.UTF8.GetBytes(jwtSecret),
|
||||
Encoding.UTF8.GetBytes("jizhi:llm-api-key-encryption:v1"));
|
||||
}
|
||||
}
|
||||
@@ -13,22 +13,26 @@ namespace MiaoJiZhang.Api.Services;
|
||||
public partial class OpenAiVisionClient : ILlmClient
|
||||
{
|
||||
private readonly IServiceScopeFactory _sf;
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<OpenAiVisionClient> _logger;
|
||||
private string? _baseUrl, _apiKey, _model, _protocol;
|
||||
private int _maxTokens = 1024;
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<OpenAiVisionClient> _logger;
|
||||
private readonly LlmSecretProtector _secretProtector;
|
||||
private string? _baseUrl, _apiKey, _model, _protocol;
|
||||
private int _maxTokens = 1024;
|
||||
private double _temperature = 0.7;
|
||||
private DateTime _last = DateTime.MinValue;
|
||||
private static readonly object _lk = new();
|
||||
|
||||
public OpenAiVisionClient(
|
||||
IServiceScopeFactory sf,
|
||||
IHttpClientFactory hf,
|
||||
ILogger<OpenAiVisionClient> logger)
|
||||
IServiceScopeFactory sf,
|
||||
IHttpClientFactory hf,
|
||||
LlmSecretProtector secretProtector,
|
||||
ILogger<OpenAiVisionClient> logger)
|
||||
{
|
||||
_sf = sf;
|
||||
_http = hf.CreateClient("LlmClient");
|
||||
_http.Timeout = TimeSpan.FromSeconds(120);
|
||||
_logger = logger;
|
||||
_http = hf.CreateClient("LlmClient");
|
||||
_http.Timeout = TimeSpan.FromSeconds(120);
|
||||
_secretProtector = secretProtector;
|
||||
_logger = logger;
|
||||
}
|
||||
public bool IsEnabled { get { Load(); return !string.IsNullOrEmpty(_apiKey); } }
|
||||
|
||||
@@ -200,6 +204,7 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
只有截图明确显示失败、取消、待支付,或明确是同一 flowSessionId 的重复结果页时才可 drop。
|
||||
只有存在没有对应候选的 evidenceId 且截图明确显示交易成功时才可 create,并必须引用该 evidenceId。
|
||||
update/create 的 type 只能是 expense、income 或 transfer,amount 必须大于 0;transfer 必须返回 transferDirection=in|out。
|
||||
update 修改 amount 时必须引用属于该 candidateId 的 evidenceId,且 confidence 不低于 0.9;证据不足时保持候选金额。
|
||||
截图未明确显示时间时沿用候选时间,禁止猜测时间。reason 不超过 40 个汉字。
|
||||
""";
|
||||
var messages = new List<object>();
|
||||
@@ -678,14 +683,22 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
|
||||
public async Task<(bool, string?)> TestConnectionAsync(CancellationToken ct = default)
|
||||
{
|
||||
Load();
|
||||
if (string.IsNullOrEmpty(_apiKey)) return (false, "API Key 为空");
|
||||
if (_protocol != "responses")
|
||||
return (false, "AI Agent 记账要求使用 Responses 协议");
|
||||
|
||||
try
|
||||
{
|
||||
var tool = new AgentToolDefinition(
|
||||
Load();
|
||||
if (string.IsNullOrEmpty(_apiKey)) return (false, "API Key 为空");
|
||||
|
||||
try
|
||||
{
|
||||
if (_protocol != "responses")
|
||||
{
|
||||
var reply = await L(
|
||||
"你正在执行连接测试,只回复 OK。",
|
||||
"测试连接",
|
||||
ct);
|
||||
return string.IsNullOrWhiteSpace(reply)
|
||||
? (false, "模型没有返回内容")
|
||||
: (true, null);
|
||||
}
|
||||
var tool = new AgentToolDefinition(
|
||||
"diagnostic_echo",
|
||||
"连接测试时必须调用的无副作用工具",
|
||||
JsonSerializer.Deserialize<JsonElement>(
|
||||
@@ -715,8 +728,13 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
var msgs = new List<object>();
|
||||
if (_protocol == "messages") msgs.Add(new { role = "user", content = s + "\n\n" + u });
|
||||
else { msgs.Add(new { role = "system", content = s }); msgs.Add(new { role = "user", content = u }); }
|
||||
await SA(BuildBody(msgs, _maxTokens, 0.7), onToken, ct);
|
||||
}
|
||||
await SA(BuildBody(msgs, _maxTokens, _temperature), onToken, ct);
|
||||
}
|
||||
|
||||
public void InvalidateConfiguration()
|
||||
{
|
||||
lock (_lk) _last = DateTime.MinValue;
|
||||
}
|
||||
|
||||
void Load()
|
||||
{
|
||||
@@ -727,23 +745,36 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
try
|
||||
{
|
||||
using var scope = _sf.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var config = db.AppConfigs.ToDictionary(x => x.Key, x => x.Value);
|
||||
_apiKey = Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
|
||||
_baseUrl = (
|
||||
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
|
||||
config.GetValueOrDefault("llm.base_url", "https://api.openai.com/v1") ??
|
||||
"").TrimEnd('/');
|
||||
_model = Environment.GetEnvironmentVariable("LLM_MODEL") ??
|
||||
config.GetValueOrDefault("llm.model", "gpt-4o-mini");
|
||||
_protocol = Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
|
||||
config.GetValueOrDefault("llm.protocol", "chat_completions");
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var config = db.AppConfigs.ToDictionary(x => x.Key, x => x.Value);
|
||||
_apiKey = _secretProtector.TryUnprotect(
|
||||
config.GetValueOrDefault(LlmSecretProtector.ConfigKey),
|
||||
out var protectedApiKey)
|
||||
? protectedApiKey
|
||||
: Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
|
||||
_baseUrl = (
|
||||
config.GetValueOrDefault("llm.base_url") ??
|
||||
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
|
||||
"https://api.openai.com/v1").TrimEnd('/');
|
||||
_model = config.GetValueOrDefault("llm.model") ??
|
||||
Environment.GetEnvironmentVariable("LLM_MODEL") ??
|
||||
"gpt-4o-mini";
|
||||
_protocol = config.GetValueOrDefault("llm.protocol") ??
|
||||
Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
|
||||
"responses";
|
||||
_maxTokens = int.TryParse(
|
||||
config.GetValueOrDefault("llm.max_tokens"),
|
||||
out var maxTokens)
|
||||
? Math.Clamp(maxTokens, 64, 4096)
|
||||
: 1024;
|
||||
_last = DateTime.UtcNow;
|
||||
? Math.Clamp(maxTokens, 64, 4096)
|
||||
: 1024;
|
||||
_temperature = double.TryParse(
|
||||
config.GetValueOrDefault("llm.temperature"),
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var temperature)
|
||||
? Math.Clamp(temperature, 0, 2)
|
||||
: 0.7;
|
||||
_last = DateTime.UtcNow;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -753,7 +784,7 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
}
|
||||
|
||||
async Task<string?> L(string sys, string user, CancellationToken ct)
|
||||
{ Load(); var msgs = new List<object>(); if (_protocol == "messages") msgs.Add(new { role = "user", content = sys + "\n\n" + user }); else { msgs.Add(new { role = "system", content = sys }); msgs.Add(new { role = "user", content = user }); } var (j, _) = await CA(BuildBody(msgs, _maxTokens, 0.7), ct); if (j is null) return null; var content = EX(j).Trim(); return content.Length > 0 ? content : null; }
|
||||
{ Load(); var msgs = new List<object>(); if (_protocol == "messages") msgs.Add(new { role = "user", content = sys + "\n\n" + user }); else { msgs.Add(new { role = "system", content = sys }); msgs.Add(new { role = "user", content = user }); } var (j, _) = await CA(BuildBody(msgs, _maxTokens, _temperature), ct); if (j is null) return null; var content = EX(j).Trim(); return content.Length > 0 ? content : null; }
|
||||
|
||||
object BuildBody(List<object> msgs, int maxT, double temp) => _protocol switch
|
||||
{ "messages" => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp }, "responses" => new { model = _model, input = msgs, max_output_tokens = maxT, temperature = temp, thinking = new { type = "disabled" } }, _ => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp } };
|
||||
|
||||
@@ -99,7 +99,7 @@ public static class DbSeeder
|
||||
|
||||
new AppConfig { Key = "llm.model", Value = "gpt-4o-mini", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "llm.max_tokens", Value = "1024", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "llm.temperature", Value = "0.8", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "llm.temperature", Value = "0.7", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "limit.daily_ai_messages", Value = "200", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "limit.daily_ai_messages_per_user", Value = "50", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "limit.max_monthly_budget", Value = "99999999", Version = 1, UpdatedAt = now },
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# 喵记账 · AI 记账 APP 设计文档
|
||||
|
||||
> 状态:UI/UX 设计阶段 · 更新于 2026-07-18
|
||||
> 状态:设计规范已落地,持续跟随实现迭代 · 更新于 2026-08-21
|
||||
|
||||
实现进度、已完成模块和当前风险见 [`STATUS.md`](STATUS.md)。本文保留产品决策、视觉规范和后台边界。
|
||||
|
||||
## 1. 产品概述
|
||||
|
||||
|
||||
+17
-14
@@ -8,11 +8,11 @@
|
||||
|
||||
### 后端版本号
|
||||
- 文件:`backend/MiaoJiZhang.Api/Program.cs`
|
||||
- 变量:`apiVersion = "20260718-1600"`(修改此行即可)
|
||||
- 配置:`Build:Version`(未注入时默认为 `dev`)
|
||||
- 显示位置:
|
||||
- `GET /api/ping` 返回 JSON `{ "version": "...", "built": "..." }`
|
||||
- `GET /api/version` 同上
|
||||
- 更新方法:修改 `apiVersion` 字符串,重启后端
|
||||
- 更新方法:通过环境变量或部署配置注入 `Build__Version`,重启后端
|
||||
|
||||
### Flutter App 版本号
|
||||
- 文件:`frontend/lib/shared/version.dart`
|
||||
@@ -22,9 +22,9 @@
|
||||
- 登录页底部
|
||||
- 「我的」页底部
|
||||
- 构建命令:
|
||||
```bash
|
||||
```powershell
|
||||
flutter clean
|
||||
flutter build apk --release --dart-define=APP_VERSION=20260718-1600
|
||||
flutter build apk --release --dart-define=APP_VERSION=20260821-135
|
||||
```
|
||||
⚠️ **必须加 `--dart-define`,否则 App 显示 `vdev`**
|
||||
|
||||
@@ -33,8 +33,7 @@
|
||||
- 更新方法:重新构建并完整替换后端静态资源目录。
|
||||
```bash
|
||||
cd admin-web && npm run build
|
||||
find ../backend/MiaoJiZhang.Api/wwwroot -mindepth 1 -delete
|
||||
cp -a dist/. ../backend/MiaoJiZhang.Api/wwwroot/
|
||||
Copy-Item -Recurse -Force dist\* ..\backend\MiaoJiZhang.Api\wwwroot\
|
||||
```
|
||||
|
||||
---
|
||||
@@ -45,7 +44,7 @@
|
||||
```bash
|
||||
cd backend
|
||||
export Admin__BootstrapUsername='admin'
|
||||
export Admin__BootstrapPassword='replace-with-a-random-password-of-at-least-12-characters'
|
||||
export Admin__BootstrapPassword='replace-with-a-password-longer-than-5-characters'
|
||||
dotnet build
|
||||
# 重启
|
||||
powershell -Command "Get-Process dotnet | Stop-Process -Force"
|
||||
@@ -57,12 +56,15 @@ dotnet run --project MiaoJiZhang.Api
|
||||
引导变量。正式环境必须使用 HTTPS 并保持 `Admin__CookieSecure=true`。本地纯 HTTP 调试时才可
|
||||
临时设置 `Admin__CookieSecure=false`。
|
||||
|
||||
后台“AI 配置 → 模型服务”可以直接保存和替换 LLM API Key。实际 API Key 使用 AES-GCM
|
||||
加密后写入配置表,加密密钥由服务端从必填的 `Jwt__Secret` 自动派生,无需增加部署变量;
|
||||
页面和接口只显示 API Key 尾号。旧的 `LLM_API_KEY` 仍作为回退配置,后台保存的密钥优先。
|
||||
|
||||
### 2. Admin Web
|
||||
```bash
|
||||
```powershell
|
||||
cd admin-web
|
||||
npm run build
|
||||
find ../backend/MiaoJiZhang.Api/wwwroot -mindepth 1 -delete
|
||||
cp -a dist/. ../backend/MiaoJiZhang.Api/wwwroot/
|
||||
Copy-Item -Recurse -Force dist\* ..\backend\MiaoJiZhang.Api\wwwroot\
|
||||
```
|
||||
浏览器打开 `http://localhost:5000/` 或 `http://{电脑IP}:5000/`。如界面未更新请 **Ctrl+Shift+R** 强制刷新。
|
||||
|
||||
@@ -71,7 +73,7 @@ cp -a dist/. ../backend/MiaoJiZhang.Api/wwwroot/
|
||||
cd frontend
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter build apk --release --dart-define=APP_VERSION=20260718-1600
|
||||
flutter build apk --release --dart-define=APP_VERSION=20260821-135
|
||||
adb install -r build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
@@ -111,10 +113,10 @@ jizhang/
|
||||
│ └── features/ # 业务页面
|
||||
├── admin-web/ # Vue3 + Ant Design 后台
|
||||
│ └── src/
|
||||
│ ├── App.vue # ★ 侧边栏版本号
|
||||
│ ├── App.vue # Admin 壳与导航
|
||||
│ └── views/ # 管理页面
|
||||
├── docs/ # 项目说明、设计规范、开发说明
|
||||
└── design/ # UI 稿、UX 原型、配色探索
|
||||
├── design/ # UI 稿、UX 原型、配色探索
|
||||
└── docs/ # 项目状态、设计、开发与客户端对接文档
|
||||
```
|
||||
|
||||
---
|
||||
@@ -136,5 +138,6 @@ jizhang/
|
||||
| 修改了代码但 App 不变 | 没有 clean 构建,或没加 dart-define | `flutter clean && flutter build apk --release --dart-define=APP_VERSION=...` |
|
||||
| 修改了代码但后台不变 | 后端还在跑旧进程 | 先 `Stop-Process -Name dotnet -Force` 再 `dotnet run` |
|
||||
| Admin Web 界面不变 | 浏览器缓存 | Ctrl+Shift+R 强制刷新,或用无痕模式打开 |
|
||||
| 数据库结构不一致 | 未执行最新 EF Migration | `dotnet ef database update` 后再启动服务 |
|
||||
| Windows 文件路径错误 | 中文路径编码 | 用 python3 读文件时加 `encoding='utf-8'` |
|
||||
| sed 破坏代码 | git-bash 的 sed 不兼容 | 禁止用 sed 改 dart/vue 源码,用 Write/Edit 工具 |
|
||||
|
||||
+13
-8
@@ -1,6 +1,8 @@
|
||||
# 喵记账 · 项目骨架
|
||||
|
||||
> Flutter 客户端 + .NET 9 后端 · 搭建于 2026-07-18
|
||||
> Flutter 客户端 + .NET 9 后端 · 当前状态更新于 2026-08-21
|
||||
|
||||
当前实现状态与待办以 [`STATUS.md`](STATUS.md) 为准;本文保留项目入口、运行方式和接口索引。
|
||||
|
||||
## 目录结构
|
||||
|
||||
@@ -50,7 +52,7 @@ jizhang/
|
||||
2. 改 `backend/MiaoJiZhang.Api/appsettings.json` 里的连接串(`your_password`)
|
||||
3. 改 `Jwt:Secret` 为 ≥32 字符的随机串
|
||||
4. `cd backend && dotnet run --project MiaoJiZhang.Api`
|
||||
- 开发期 `Program.cs` 会 `EnsureCreated` 建表 + 跑种子数据
|
||||
- 数据库结构通过 `MiaoJiZhang.Infrastructure/Persistence/Migrations` 维护;本地首次启动前执行 `dotnet ef database update`
|
||||
- OpenAPI 文档:`http://localhost:5000/openapi/v1.json`
|
||||
5. 测试注册:
|
||||
```bash
|
||||
@@ -93,15 +95,18 @@ flutter run
|
||||
9. ~~性格设置页~~ ✅(PUT /api/users/me/companion + CompanionPage,形象/性格/三滑杆)
|
||||
10. ~~分类管理~~ ✅(POST/DELETE /api/categories + CategoryManagePage,自定义分类增删)
|
||||
11. ~~语音/OCR 流程~~ ✅(POST /api/parse + ParseSheet 确认入账;ASR/OCR SDK 接入后自动填充文本)
|
||||
12. ~~LLM 接入层~~ ✅(ILlmClient 抽象 + NullLlmClient 回退规则版;对接真实 LLM 时实现该接口即可)
|
||||
12. ~~LLM 接入层~~ ✅(已有客户端抽象、权限/额度和回退路径;仍需真实环境联调)
|
||||
|
||||
13. ~~Admin Web 后台~~ ✅(配置、分类、AI 形象/性格、表情包、用户、推送、审计与管理员账号页面已存在)
|
||||
14. ~~EF Core Migration~~ ✅(已有多次迁移;发布前仍需执行全新库和升级库验证)
|
||||
|
||||
## 遗留(需要外部资源/发布前处理)
|
||||
|
||||
- ASR(语音转文字)与拍照 OCR 的 SDK 选型接入(ParseSheet 已留好入口,接入后把识别文本传入即可)
|
||||
- 真实 LLM 对接(实现 ILlmClient;性格 Prompt 已在 AiPersona 表可后台调)
|
||||
- SVG 图标(category_icon.dart / stickerStyle 集中管理,直接替换映射即可)
|
||||
- Admin Web 后台(`docs/DESIGN.md` 第 8 节;实体 AppConfig/AiPersona/AiAvatar/Sticker 已建好)
|
||||
- EF Migration 替代 EnsureCreated;Jwt Secret 换生产值;baseUrl 环境化
|
||||
- 真实 LLM、OCR/ASR、截图识别和推送供应商的生产配置与联调
|
||||
- JWT Secret、管理员引导变量、数据库连接串、API Base URL 和更新下载地址环境化
|
||||
|
||||
## 已完成的接口清单
|
||||
|
||||
@@ -129,6 +134,6 @@ flutter run
|
||||
|
||||
## 技术债 / 注意
|
||||
|
||||
- 后端用 `EnsureCreated` 跳过 Migration,便于快速起盘;正式环境前要切到 `dotnet ef migrations add Init`
|
||||
- `appsettings.json` 的 `Jwt:Secret` 是占位值,**生产必须换**
|
||||
- 前端 `main_shell.dart` 的 5 个 branch 暂用 `Placeholder()`,下一步逐个填真实页面
|
||||
- `Build:Version` 未注入时后端显示 `dev`;发布构建必须显式注入版本。
|
||||
- `appsettings.json` 的 JWT、数据库和外部服务配置不能直接用于生产。
|
||||
- Admin Web 的 `wwwroot/assets` 是构建生成物;本地构建后不要把临时 hashed 文件混入提交。
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# 记之 · 当前项目状态
|
||||
|
||||
> 更新时间:2026-08-21
|
||||
|
||||
## 总览
|
||||
|
||||
项目已经从 UI/接口骨架进入「客户端、后台和服务端能力联调」阶段。当前主干包含 Flutter 客户端、.NET 9 API、Vue 3 管理后台、数据库迁移和自动化测试;`main` 与 `origin/main` 当前指向同一提交 `8828851`。
|
||||
|
||||
## 已完成模块
|
||||
|
||||
### 后端 API
|
||||
|
||||
- 用户注册、登录、JWT、用户资料、引导和双模式切换。
|
||||
- 账本、系统/自定义分类、手动记账、月账单、统计、搜索和软删除回收站。
|
||||
- AI 聊天、意图识别、AI 直接入账、来源追溯、预算、月报、表情包和 AI 伙伴设置。
|
||||
- 文本解析入口、截图识别相关接口、识别幂等和 AI 权限/额度控制。
|
||||
- 管理后台 API:品牌配置、系统分类、AI 形象/性格、表情包、用户、推送、管理员账号、审计和密码变更。
|
||||
- 账户注销清理、数据导出/恢复、同步冲突和访客数据合并相关服务。
|
||||
- EF Core/MySQL 迁移已存在,覆盖初始模型、账户注销、分类颜色、AI 权限、额度、推送基础设施和转账/后台安全等变更。
|
||||
|
||||
### Flutter 客户端
|
||||
|
||||
- 启动、登录/注册、引导选模式和 AI 伙伴。
|
||||
- 普通模式首页、账本切换、手动记账、账单详情/编辑、聊天和全 AI 模式。
|
||||
- 统计、AI 月报、预算、搜索、分类管理、伙伴设置、回收站、数据管理和推送设置。
|
||||
- 语音/OCR 确认页、截图识别设置/批量导入、识别诊断、离线提示、同步冲突和本地数据导出。
|
||||
- 客户端更新检查、推送注册和本地数据库/会话恢复。
|
||||
|
||||
当前 `frontend/pubspec.yaml` 版本为 `1.2.5+135`;发布时仍需使用正式构建参数和发布说明。
|
||||
|
||||
### Admin Web
|
||||
|
||||
已具备登录、强制改密、仪表盘、系统设置、品牌配置、系统分类、AI 性格、AI 形象、表情包、用户、推送、管理员账号和审计页面。管理员角色权限由路由和后端共同控制。
|
||||
|
||||
### 测试
|
||||
|
||||
- 后端:API 集成测试、推送集成测试、推送供应商测试。
|
||||
- Flutter:模型、主题/时间、本地导出、本地转移数据库、更新流程、视觉契约和 Widget 测试。
|
||||
|
||||
## 当前待办
|
||||
|
||||
### P0:联调与发布阻塞项
|
||||
|
||||
- 在真实环境验证 LLM、OCR/ASR、截图识别和推送供应商配置。
|
||||
- 验证最新 EF Migration 在全新库、升级库和回滚/重试场景下的行为。
|
||||
- 将数据库连接串、JWT Secret、管理员引导变量、API Base URL 和更新下载地址全部改为部署环境注入。
|
||||
- 完成 Android 内部构建的截图权限、后台识别、证书和网络安全回归。
|
||||
|
||||
### P1:质量与体验
|
||||
|
||||
- 对核心流程做端到端回归:注册 → 引导 → 记账 → AI 解析 → 撤销 → 月报/预算。
|
||||
- 完善错误提示、离线待同步、同步冲突和外部服务失败时的降级行为。
|
||||
- 检查 Admin Web 发布资源生成策略,避免把本地 hashed 静态文件和构建目录带入工作区。
|
||||
|
||||
### P2:后续演进
|
||||
|
||||
- 完成正式 SVG/插画资源和更多运营内容配置。
|
||||
- 补充后台数据看板、脱敏意图失败样本和内容版本回滚。
|
||||
- 完成各平台正式签名、应用商店元数据和更新渠道配置。
|
||||
|
||||
## 工作区清理记录
|
||||
|
||||
2026-08-21 已清理未跟踪的完整备份、历史部署、`.codex-tools` 缓存、Codex 临时构建/补丁目录,以及后端 `wwwroot/assets` 的未跟踪生成资源。源码、`docs/`、设计原型、APK、Android 新代码和测试文件未删除。
|
||||
|
||||
## 建议验证顺序
|
||||
|
||||
1. 启动 MySQL,执行 `dotnet ef database update`。
|
||||
2. 配置后端外部服务和管理员引导变量,运行 API 集成测试。
|
||||
3. 构建 Admin Web,验证登录、权限、配置和发布资源。
|
||||
4. 运行 Flutter 测试,再构建 `1.2.5+135` 内部包。
|
||||
5. 用真实设备验证截图识别、推送、离线同步和更新检查。
|
||||
@@ -4,8 +4,10 @@
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
@@ -50,55 +52,80 @@
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/ProjectionConsentTheme"/>
|
||||
|
||||
<service
|
||||
android:name=".OneShotProjectionService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaProjection"
|
||||
android:process=":recognition"
|
||||
android:stopWithTask="false"/>
|
||||
<service
|
||||
android:name=".OneShotProjectionService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaProjection"
|
||||
android:process=":projection"
|
||||
android:stopWithTask="false"/>
|
||||
|
||||
<service
|
||||
android:name=".ScreenshotTileService"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/screenshot_tile_label"
|
||||
android:process=":recognition"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<service
|
||||
android:name=".ScreenshotTileService"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/screenshot_tile_label"
|
||||
android:process=":recognition"
|
||||
android:stopWithTask="false"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".PaymentNotificationListenerService"
|
||||
android:exported="true"
|
||||
android:label="@string/notification_listener_label"
|
||||
android:process=":recognition"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
||||
<service
|
||||
android:name=".PaymentNotificationListenerService"
|
||||
android:exported="true"
|
||||
android:label="@string/notification_listener_label"
|
||||
android:process=":recognition"
|
||||
android:stopWithTask="false"
|
||||
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.notification.NotificationListenerService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".ScreenshotAccessibilityService"
|
||||
android:exported="true"
|
||||
android:process=":recognition"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<service
|
||||
android:name=".ScreenshotAccessibilityService"
|
||||
android:exported="true"
|
||||
android:process=":recognition"
|
||||
android:stopWithTask="false"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService"/>
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/accessibility_service_config"/>
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name=".RecognitionBridgeProvider"
|
||||
android:authorities="${applicationId}.recognition.bridge"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="false"
|
||||
android:process=":recognition"/>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".RecognitionKeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:process=":recognition"
|
||||
android:stopWithTask="false">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Keeps user-enabled payment accessibility and notification recognition active"/>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".RecognitionKeepAliveReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:process=":recognition">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name=".RecognitionBridgeProvider"
|
||||
android:authorities="${applicationId}.recognition.bridge"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="false"
|
||||
android:process=":recognition"/>
|
||||
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
|
||||
@@ -101,7 +101,7 @@ object LocalPaymentOcr {
|
||||
flowSessionId: String,
|
||||
trustedFlow: Boolean,
|
||||
capturedAt: Long,
|
||||
expectedAmountCents: Long?,
|
||||
expectedAmountEvidence: ExpectedAmountEvidence?,
|
||||
expectedType: String?,
|
||||
resultTransitionObserved: Boolean,
|
||||
submittedFlow: Boolean,
|
||||
@@ -120,7 +120,7 @@ object LocalPaymentOcr {
|
||||
flowSessionId = flowSessionId,
|
||||
trustedFlow = trustedFlow,
|
||||
capturedAt = capturedAt,
|
||||
expectedAmountCents = expectedAmountCents,
|
||||
expectedAmountEvidence = expectedAmountEvidence,
|
||||
expectedType = expectedType,
|
||||
resultTransitionObserved = resultTransitionObserved,
|
||||
submittedFlow = submittedFlow,
|
||||
@@ -199,7 +199,7 @@ object LocalPaymentOcr {
|
||||
flowSessionId: String,
|
||||
trustedFlow: Boolean,
|
||||
capturedAt: Long,
|
||||
expectedAmountCents: Long?,
|
||||
expectedAmountEvidence: ExpectedAmountEvidence?,
|
||||
expectedType: String?,
|
||||
resultTransitionObserved: Boolean,
|
||||
submittedFlow: Boolean,
|
||||
@@ -322,6 +322,7 @@ object LocalPaymentOcr {
|
||||
),
|
||||
)
|
||||
val distinctCents = ranked.map { (it.amount * 100).roundToLong() }.distinct()
|
||||
val expectedAmountCents = expectedAmountEvidence?.amountCents
|
||||
val expectedCandidate = expectedAmountCents?.let { expected ->
|
||||
ranked.firstOrNull { (it.amount * 100).roundToLong() == expected }
|
||||
}
|
||||
@@ -348,15 +349,21 @@ object LocalPaymentOcr {
|
||||
|
||||
val selectedCents = resultSelectedCents ?: expectedAmountCents!!
|
||||
val amountSource = if (resultSelectedCents == null) "expected" else "result"
|
||||
val expectedMatched = expectedAmountCents?.let { it == selectedCents }
|
||||
val expectedMatched = if (expectedAmountCents != null && resultSelectedCents != null) {
|
||||
expectedAmountCents == resultSelectedCents
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val amountConflict = expectedMatched == false
|
||||
val fresh = System.currentTimeMillis() - capturedAt <= MAX_SCREENSHOT_AGE_MS
|
||||
val resultAmountSafe = distinctCents.size == 1 &&
|
||||
(expectedAmountCents == null || expectedMatched == true)
|
||||
val highConfidence = when {
|
||||
amountSource == "expected" -> trustedFlow && fresh && submittedFlow &&
|
||||
resultTransitionObserved && expectedType == direction
|
||||
amountSource == "expected" -> expectedAmountEvidence?.isStrong == true &&
|
||||
trustedFlow && fresh && submittedFlow && resultTransitionObserved &&
|
||||
expectedType == direction
|
||||
status.strength == PaymentStatusStrength.STRONG -> trustedFlow && fresh &&
|
||||
submittedFlow && resultTransitionObserved && resultAmountSafe
|
||||
submittedFlow && resultTransitionObserved && resultAmountSafe && !amountConflict
|
||||
status.strength == PaymentStatusStrength.WEAK -> OcrEvidenceEvaluator.qualifiesWeakAuto(
|
||||
trustedFlow = trustedFlow && submittedFlow,
|
||||
freshScreenshot = fresh,
|
||||
@@ -399,8 +406,27 @@ object LocalPaymentOcr {
|
||||
if (it == "income") "in" else "out"
|
||||
},
|
||||
counterparty = merchant.takeIf { kind == "transfer" },
|
||||
amountEvidenceStrength = if (amountSource == "result" ||
|
||||
expectedAmountEvidence?.isStrong == true
|
||||
) {
|
||||
"strong"
|
||||
} else {
|
||||
"weak"
|
||||
},
|
||||
amountCandidateCount = distinctCents.size,
|
||||
expectedAmountMatched = expectedMatched,
|
||||
amountIssueReason = when {
|
||||
amountConflict -> "result_amount_conflict"
|
||||
amountSource == "expected" && expectedAmountEvidence?.isStrong != true ->
|
||||
"weak_expected_fallback"
|
||||
amountSource == "expected" -> "expected_amount_fallback"
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
val reason = when {
|
||||
amountConflict -> "result_amount_conflict"
|
||||
amountSource == "expected" && expectedAmountEvidence?.isStrong != true ->
|
||||
"weak_expected_fallback"
|
||||
highConfidence && amountSource == "expected" -> "expected_amount_fallback"
|
||||
highConfidence && status.strength == PaymentStatusStrength.WEAK ->
|
||||
"combined_high_confidence"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.nx.miaoji
|
||||
|
||||
import android.Manifest
|
||||
import android.app.StatusBarManager
|
||||
import android.Manifest
|
||||
import android.app.ActivityManager
|
||||
import android.app.ApplicationExitInfo
|
||||
import android.app.StatusBarManager
|
||||
import android.app.UiModeManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
@@ -44,11 +46,13 @@ class MainActivity : FlutterActivity() {
|
||||
const val ACTION_RECOGNITION_UNDO = "recognition_undo"
|
||||
const val ACTION_RECOGNITION_EDIT = "recognition_edit"
|
||||
const val ACTION_RECOGNITION_BATCH_REVIEW = "recognition_batch_review"
|
||||
const val ACTION_OPEN_RECOGNITION_SETTINGS = "open_recognition_settings"
|
||||
const val EXTRA_SCREENSHOT_PATH = "screenshotPath"
|
||||
const val EXTRA_SCREENSHOT_ERROR = "screenshotError"
|
||||
const val EXTRA_SCREENSHOT_SESSION_ID = "screenshotSessionId"
|
||||
const val EXTRA_TRANSACTION_ID = "transactionId"
|
||||
}
|
||||
const val EXTRA_SCREENSHOT_SESSION_ID = "screenshotSessionId"
|
||||
const val EXTRA_TRANSACTION_ID = "transactionId"
|
||||
private const val TASK_CLEANER_WINDOW_MS = 15_000L
|
||||
}
|
||||
|
||||
private val handler by lazy { Handler(mainLooper) }
|
||||
private var channel: MethodChannel? = null
|
||||
@@ -68,7 +72,12 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
private var pendingSpeechResult: MethodChannel.Result? = null
|
||||
private var speechRecognizer: SpeechRecognizer? = null
|
||||
private var streamingSpeech = false
|
||||
private var streamingSpeech = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
updateRecentsProtection()
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
@@ -120,8 +129,8 @@ class MainActivity : FlutterActivity() {
|
||||
"getRecognitionStatus" -> {
|
||||
result.success(recognitionStatus())
|
||||
}
|
||||
"setRecognitionToggle" -> {
|
||||
val response = RecognitionBridge.call(
|
||||
"setRecognitionToggle" -> {
|
||||
val response = RecognitionBridge.call(
|
||||
this,
|
||||
RecognitionBridgeProvider.METHOD_SET_TOGGLE,
|
||||
extras = Bundle().apply {
|
||||
@@ -138,7 +147,7 @@ class MainActivity : FlutterActivity() {
|
||||
)
|
||||
result.success(response?.getBoolean("success") == true)
|
||||
}
|
||||
"configureRecognitionContext" -> {
|
||||
"configureRecognitionContext" -> {
|
||||
val response = RecognitionBridge.call(
|
||||
this,
|
||||
RecognitionBridgeProvider.METHOD_SET_RUNTIME,
|
||||
@@ -181,7 +190,16 @@ class MainActivity : FlutterActivity() {
|
||||
putString("candidateId", call.argument<String>("candidateId"))
|
||||
},
|
||||
)
|
||||
result.success(response?.getBoolean("success") == true)
|
||||
val changed = response?.getBoolean("success") == true
|
||||
if (changed) {
|
||||
updateRecentsProtection(
|
||||
response.getBoolean("keepAliveExpected"),
|
||||
)
|
||||
}
|
||||
result.success(changed)
|
||||
}
|
||||
"ensureRecognitionKeepAlive" -> {
|
||||
result.success(ensureRecognitionKeepAlive())
|
||||
}
|
||||
"openAccessibilitySettings" -> {
|
||||
startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
@@ -223,17 +241,26 @@ class MainActivity : FlutterActivity() {
|
||||
super.onResume()
|
||||
updateInstallBridge?.onResume()
|
||||
vendorPushBridge?.onResume()
|
||||
scheduleShortcutIfNeeded()
|
||||
dispatchPendingScreenshot()
|
||||
dispatchPendingRecognitionAction()
|
||||
scheduleShortcutIfNeeded()
|
||||
dispatchPendingScreenshot()
|
||||
dispatchPendingRecognitionAction()
|
||||
ensureRecognitionKeepAlive()
|
||||
updateRecentsProtection()
|
||||
}
|
||||
|
||||
private fun handleIncomingIntent(incoming: Intent?) {
|
||||
when (incoming?.getStringExtra(EXTRA_ACTION)) {
|
||||
ACTION_SCREENSHOT_SHORTCUT -> {
|
||||
shortcutQueued = true
|
||||
scheduleShortcutIfNeeded()
|
||||
}
|
||||
ACTION_SCREENSHOT_SHORTCUT -> {
|
||||
shortcutQueued = true
|
||||
scheduleShortcutIfNeeded()
|
||||
}
|
||||
ACTION_OPEN_RECOGNITION_SETTINGS -> {
|
||||
pendingRecognitionAction = mapOf(
|
||||
"action" to ACTION_OPEN_RECOGNITION_SETTINGS,
|
||||
)
|
||||
dispatchPendingRecognitionAction()
|
||||
incoming.removeExtra(EXTRA_ACTION)
|
||||
}
|
||||
ACTION_SCREENSHOT_RESULT -> {
|
||||
val path = incoming.getStringExtra(EXTRA_SCREENSHOT_PATH)
|
||||
val sessionId =
|
||||
@@ -778,8 +805,8 @@ class MainActivity : FlutterActivity() {
|
||||
},
|
||||
)
|
||||
}
|
||||
private fun recognitionStatus(): Map<String, Any?> {
|
||||
val response = RecognitionBridge.call(
|
||||
private fun recognitionStatus(): Map<String, Any?> {
|
||||
val response = RecognitionBridge.call(
|
||||
this,
|
||||
RecognitionBridgeProvider.METHOD_STATUS,
|
||||
)
|
||||
@@ -787,13 +814,58 @@ class MainActivity : FlutterActivity() {
|
||||
contentResolver,
|
||||
"enabled_notification_listeners",
|
||||
).orEmpty()
|
||||
val notificationAuthorized = enabledListeners
|
||||
val notificationAuthorized = enabledListeners
|
||||
.split(':')
|
||||
.mapNotNull(ComponentName::unflattenFromString)
|
||||
.any { it == ComponentName(this, PaymentNotificationListenerService::class.java) }
|
||||
return mapOf(
|
||||
"accessibilityAuthorized" to isAccessibilityEnabledInSystem(),
|
||||
"accessibilityConnected" to (response?.getBoolean("accessibilityConnected") == true),
|
||||
.any { it == ComponentName(this, PaymentNotificationListenerService::class.java) }
|
||||
val accessibilityAuthorized = isAccessibilityEnabledInSystem()
|
||||
val accessibilityConnected = response?.getBoolean("accessibilityConnected") == true
|
||||
val recognitionProcessStartedAt = response?.getLong("recognitionProcessStartedAt") ?: 0L
|
||||
val keepAliveExpected = response?.getBoolean("keepAliveExpected") == true
|
||||
val exits = recognitionExitHistory()
|
||||
val recognitionExits = exits.filter {
|
||||
it.processName == "$packageName:recognition"
|
||||
}
|
||||
val exit = (recognitionExits.ifEmpty { exits }).maxByOrNull { it.timestamp }
|
||||
val lastConnectedAt = response?.getLong("accessibilityLastConnectedAt") ?: 0L
|
||||
val taskRemovedAt = RecognitionConnectionStore.lastTaskRemovedAt(this)
|
||||
val cleanerExit = exits
|
||||
.filter { isTaskCleanerExit(it, taskRemovedAt) }
|
||||
.maxByOrNull { it.timestamp }
|
||||
val taskCleanerRecoveryNeeded = accessibilityAuthorized &&
|
||||
!accessibilityConnected &&
|
||||
cleanerExit != null &&
|
||||
cleanerExit.timestamp > lastConnectedAt
|
||||
val recentsProtectionExpected = isOriginOsDevice() && keepAliveExpected
|
||||
if (RecognitionConnectionStore.recentsProtectionActive(this) !=
|
||||
recentsProtectionExpected
|
||||
) {
|
||||
updateRecentsProtection(keepAliveExpected)
|
||||
}
|
||||
val accessibilityConnectionState = when {
|
||||
!accessibilityAuthorized -> "unauthorized"
|
||||
accessibilityConnected -> "connected"
|
||||
taskCleanerRecoveryNeeded -> "disconnected"
|
||||
keepAliveExpected && recognitionProcessStartedAt > 0L &&
|
||||
System.currentTimeMillis() - recognitionProcessStartedAt < 2_000L -> "reconnecting"
|
||||
else -> "disconnected"
|
||||
}
|
||||
return mapOf(
|
||||
"accessibilityAuthorized" to accessibilityAuthorized,
|
||||
"accessibilityConnected" to accessibilityConnected,
|
||||
"accessibilityConnectionState" to accessibilityConnectionState,
|
||||
"accessibilityLastConnectedAt" to lastConnectedAt,
|
||||
"accessibilityLastDisconnectedAt" to (response?.getLong("accessibilityLastDisconnectedAt") ?: 0L),
|
||||
"recognitionProcessStartedAt" to recognitionProcessStartedAt,
|
||||
"keepAliveExpected" to keepAliveExpected,
|
||||
"keepAliveRunning" to (response?.getBoolean("keepAliveRunning") == true),
|
||||
"keepAliveError" to response?.getString("keepAliveError"),
|
||||
"recentsProtectionExpected" to recentsProtectionExpected,
|
||||
"recentsProtectionActive" to RecognitionConnectionStore
|
||||
.recentsProtectionActive(this),
|
||||
"lastRecognitionExitAt" to (exit?.timestamp ?: 0L),
|
||||
"lastRecognitionExitReason" to exit?.let(::recognitionExitReason),
|
||||
"taskCleanerRecoveryNeeded" to taskCleanerRecoveryNeeded,
|
||||
"notificationAuthorized" to notificationAuthorized,
|
||||
"notificationConnected" to (response?.getBoolean("notificationConnected") == true),
|
||||
"postNotificationsGranted" to (
|
||||
@@ -813,7 +885,77 @@ class MainActivity : FlutterActivity() {
|
||||
"latestStatus" to response?.getString("latestStatus"),
|
||||
"latestDiagnostic" to response?.getString("latestDiagnostic"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureRecognitionKeepAlive(): Boolean {
|
||||
val response = RecognitionBridge.call(
|
||||
this,
|
||||
RecognitionBridgeProvider.METHOD_ENSURE_KEEP_ALIVE,
|
||||
)
|
||||
return response?.getBoolean("success") == true
|
||||
}
|
||||
|
||||
private fun updateRecentsProtection(expectedOverride: Boolean? = null) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) return
|
||||
val recognitionExpected = expectedOverride ?: (
|
||||
RecognitionBridge.call(
|
||||
this,
|
||||
RecognitionBridgeProvider.METHOD_STATUS,
|
||||
)?.getBoolean("keepAliveExpected") == true
|
||||
)
|
||||
val shouldProtect = isOriginOsDevice() && recognitionExpected
|
||||
val activityManager = getSystemService(ActivityManager::class.java)
|
||||
val currentTask = activityManager.appTasks.firstOrNull {
|
||||
runCatching { it.taskInfo.taskId == taskId }.getOrDefault(false)
|
||||
} ?: activityManager.appTasks.firstOrNull()
|
||||
val applied = runCatching {
|
||||
currentTask?.setExcludeFromRecents(shouldProtect)
|
||||
currentTask != null
|
||||
}.getOrDefault(false)
|
||||
RecognitionConnectionStore.setRecentsProtectionActive(
|
||||
this,
|
||||
shouldProtect && applied,
|
||||
)
|
||||
}
|
||||
|
||||
private fun isOriginOsDevice(): Boolean {
|
||||
val vendor = "${Build.MANUFACTURER} ${Build.BRAND}".lowercase()
|
||||
return "vivo" in vendor || "iqoo" in vendor
|
||||
}
|
||||
|
||||
private fun recognitionExitHistory(): List<ApplicationExitInfo> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return emptyList()
|
||||
return runCatching {
|
||||
getSystemService(ActivityManager::class.java)
|
||||
.getHistoricalProcessExitReasons(packageName, 0, 16)
|
||||
.filter { info ->
|
||||
info.processName == packageName ||
|
||||
info.processName == "$packageName:recognition"
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun isTaskCleanerExit(info: ApplicationExitInfo, taskRemovedAt: Long): Boolean {
|
||||
return info.description.orEmpty().contains("single-cleaner", ignoreCase = true) ||
|
||||
(info.reason == ApplicationExitInfo.REASON_LOW_MEMORY &&
|
||||
taskRemovedAt > 0L &&
|
||||
kotlin.math.abs(info.timestamp - taskRemovedAt) <= TASK_CLEANER_WINDOW_MS)
|
||||
}
|
||||
|
||||
private fun recognitionExitReason(info: ApplicationExitInfo): String {
|
||||
val reason = when (info.reason) {
|
||||
ApplicationExitInfo.REASON_LOW_MEMORY -> "low_memory"
|
||||
ApplicationExitInfo.REASON_USER_REQUESTED -> "user_requested"
|
||||
ApplicationExitInfo.REASON_CRASH -> "crash"
|
||||
ApplicationExitInfo.REASON_CRASH_NATIVE -> "native_crash"
|
||||
ApplicationExitInfo.REASON_ANR -> "anr"
|
||||
ApplicationExitInfo.REASON_SIGNALED -> "signaled"
|
||||
ApplicationExitInfo.REASON_OTHER -> "other"
|
||||
else -> "reason_${info.reason}"
|
||||
}
|
||||
val description = info.description.orEmpty()
|
||||
return if (description.isBlank()) reason else "$reason: $description"
|
||||
}
|
||||
|
||||
private fun acknowledgeRecognition(call: MethodCall, result: MethodChannel.Result) {
|
||||
val response = RecognitionBridge.call(
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ class PaymentNotificationListenerService : NotificationListenerService() {
|
||||
override fun onListenerConnected() {
|
||||
super.onListenerConnected()
|
||||
isConnected = true
|
||||
RecognitionKeepAliveService.ensureRunning(this)
|
||||
Log.i(TAG, "Notification recognition listener connected")
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,36 @@ data class PaymentSignal(
|
||||
val identityConfidence: String = "strong",
|
||||
val transferDirection: String? = null,
|
||||
val counterparty: String? = null,
|
||||
val amountEvidenceStrength: String = "strong",
|
||||
val amountCandidateCount: Int = 1,
|
||||
val expectedAmountMatched: Boolean? = null,
|
||||
val amountIssueReason: String? = null,
|
||||
)
|
||||
|
||||
data class ExpectedAmountEvidence(
|
||||
val amountCents: Long,
|
||||
val flowSessionId: String,
|
||||
val windowId: Int,
|
||||
val pageFingerprint: String,
|
||||
val capturedAtEpochMs: Long,
|
||||
val candidateCount: Int,
|
||||
val source: String,
|
||||
val strength: String,
|
||||
) {
|
||||
val isStrong: Boolean get() = strength == "strong"
|
||||
|
||||
fun belongsTo(flowId: String, flowStartedAt: Long, committedAt: Long?): Boolean =
|
||||
flowSessionId == flowId &&
|
||||
capturedAtEpochMs >= flowStartedAt &&
|
||||
(committedAt == null || capturedAtEpochMs <= committedAt)
|
||||
}
|
||||
|
||||
data class PaymentParseOutcome(
|
||||
val signal: PaymentSignal?,
|
||||
val amountCandidateCount: Int,
|
||||
val resultAmountCents: Long?,
|
||||
val expectedAmountMatched: Boolean?,
|
||||
val reason: String?,
|
||||
)
|
||||
|
||||
enum class PaymentStatusStrength(val wireValue: String) {
|
||||
@@ -44,7 +74,7 @@ object PaymentParser {
|
||||
val supportedPackages = setOf(WECHAT, ALIPAY)
|
||||
|
||||
private val amountPatterns = listOf(
|
||||
Regex("""(?:实付|付款金额|支付金额|收款金额|到账金额|交易金额|金额)[::\s]*[¥¥]?\s*([0-9]+(?:\.[0-9]{1,2})?)"""),
|
||||
Regex("""(?:实付|付款金额|支付金额|转账金额|红包金额|收款金额|到账金额|交易金额|金额)[::\s]*[¥¥]?\s*([0-9]+(?:\.[0-9]{1,2})?)"""),
|
||||
Regex("""[¥¥]\s*([0-9]+(?:\.[0-9]{1,2})?)"""),
|
||||
Regex("""([0-9]+(?:\.[0-9]{1,2})?)\s*元"""),
|
||||
)
|
||||
@@ -95,7 +125,8 @@ object PaymentParser {
|
||||
)
|
||||
private val paymentInputWords = listOf(
|
||||
"输入支付密码", "请输入支付密码", "确认转账", "确认支付", "确认付款",
|
||||
"立即支付", "立即付款", "继续付款",
|
||||
"立即支付", "立即付款", "继续付款", "转账全额", "添加转账说明",
|
||||
"请输入转账金额", "输入转账金额",
|
||||
)
|
||||
private val redPacketSendContextWords = listOf(
|
||||
"发红包", "塞钱进红包", "红包金额", "发送红包", "普通红包", "拼手气红包",
|
||||
@@ -128,17 +159,47 @@ object PaymentParser {
|
||||
windowId: Int,
|
||||
flowSessionId: String? = null,
|
||||
trustedFlow: Boolean = false,
|
||||
expectedAmountCents: Long? = null,
|
||||
expectedAmountEvidence: ExpectedAmountEvidence? = null,
|
||||
expectedType: String? = null,
|
||||
resultTransitionObserved: Boolean = false,
|
||||
submittedFlow: Boolean = false,
|
||||
flowKind: String? = null,
|
||||
): PaymentSignal? {
|
||||
): PaymentSignal? = fromAccessibilityOutcome(
|
||||
packageName = packageName,
|
||||
text = text,
|
||||
eventTime = eventTime,
|
||||
windowId = windowId,
|
||||
flowSessionId = flowSessionId,
|
||||
trustedFlow = trustedFlow,
|
||||
expectedAmountEvidence = expectedAmountEvidence,
|
||||
expectedType = expectedType,
|
||||
resultTransitionObserved = resultTransitionObserved,
|
||||
submittedFlow = submittedFlow,
|
||||
flowKind = flowKind,
|
||||
).signal
|
||||
|
||||
fun fromAccessibilityOutcome(
|
||||
packageName: String,
|
||||
text: String,
|
||||
eventTime: Long,
|
||||
windowId: Int,
|
||||
flowSessionId: String? = null,
|
||||
trustedFlow: Boolean = false,
|
||||
expectedAmountEvidence: ExpectedAmountEvidence? = null,
|
||||
expectedType: String? = null,
|
||||
resultTransitionObserved: Boolean = false,
|
||||
submittedFlow: Boolean = false,
|
||||
flowKind: String? = null,
|
||||
): PaymentParseOutcome {
|
||||
val expectedEvidence = expectedAmountEvidence
|
||||
val expectedCents = expectedEvidence?.amountCents
|
||||
val resultCandidates = amountCandidateCents(text)
|
||||
val uniqueResultAmount = resultCandidates.singleOrNull()
|
||||
if (packageName !in supportedPackages ||
|
||||
containsBlockedStatus(text) ||
|
||||
isHistoryPageText(text) ||
|
||||
isPaymentInputPage(text)
|
||||
) return null
|
||||
) return PaymentParseOutcome(null, resultCandidates.size, null, null, null)
|
||||
val eventId = flowSessionId?.let { "a:" + packageName + ":" + it }
|
||||
?: "a:" + windowId + ":" + sha256(normalize(text)) + ":" + (eventTime / 10_000L)
|
||||
val status = detectStatus(text, expectedType)
|
||||
@@ -156,46 +217,85 @@ object PaymentParser {
|
||||
null
|
||||
}
|
||||
if (parsed != null) {
|
||||
val uniqueResultAmount = uniqueAmountCents(text)
|
||||
val standaloneRedPacketIncome = parsed.recognitionKind in setOf(
|
||||
"red_packet_receive",
|
||||
"red_packet_refund",
|
||||
)
|
||||
val expectedMatches = expectedAmountCents == null ||
|
||||
uniqueResultAmount == expectedAmountCents
|
||||
val expectedMatches = expectedCents == null ||
|
||||
uniqueResultAmount == expectedCents
|
||||
val conflict = expectedCents != null && uniqueResultAmount != null &&
|
||||
uniqueResultAmount != expectedCents
|
||||
val evidenceHigh = when {
|
||||
standaloneRedPacketIncome -> uniqueResultAmount == parsed.amountCents
|
||||
submittedFlow -> trustedFlow && resultTransitionObserved &&
|
||||
uniqueResultAmount == parsed.amountCents && expectedMatches
|
||||
uniqueResultAmount == parsed.amountCents && expectedMatches && !conflict
|
||||
else -> false
|
||||
}
|
||||
return parsed.copy(
|
||||
val signal = parsed.copy(
|
||||
evidenceConfidence = if (evidenceHigh) "high" else "confirm",
|
||||
amountEvidenceStrength = if (uniqueResultAmount != null) "strong" else "weak",
|
||||
amountCandidateCount = resultCandidates.size,
|
||||
expectedAmountMatched = if (expectedCents != null && uniqueResultAmount != null) {
|
||||
expectedMatches
|
||||
} else {
|
||||
null
|
||||
},
|
||||
amountIssueReason = if (conflict) "result_amount_conflict" else null,
|
||||
)
|
||||
return PaymentParseOutcome(
|
||||
signal,
|
||||
resultCandidates.size,
|
||||
uniqueResultAmount,
|
||||
signal.expectedAmountMatched,
|
||||
signal.amountIssueReason,
|
||||
)
|
||||
}
|
||||
|
||||
val redPacketSentSurface = flowKind == "red_packet_send" &&
|
||||
hasRedPacketSentSurface(text)
|
||||
if (status.strength == PaymentStatusStrength.NONE && !redPacketSentSurface) return null
|
||||
val direction = status.direction ?: if (redPacketSentSurface) "expense" else return null
|
||||
val resultAmount = uniqueAmountCents(text)
|
||||
if (status.strength == PaymentStatusStrength.NONE && !redPacketSentSurface) {
|
||||
return PaymentParseOutcome(null, resultCandidates.size, null, null, null)
|
||||
}
|
||||
val direction = status.direction ?: if (redPacketSentSurface) "expense" else {
|
||||
return PaymentParseOutcome(null, resultCandidates.size, null, null, "direction_unknown")
|
||||
}
|
||||
val resultAmount = uniqueResultAmount
|
||||
val canUseExpectedAmount = resultAmount == null &&
|
||||
expectedAmountCents != null &&
|
||||
expectedCents != null &&
|
||||
submittedFlow &&
|
||||
resultTransitionObserved &&
|
||||
expectedType == direction
|
||||
val amountCents = resultAmount ?: expectedAmountCents?.takeIf { canUseExpectedAmount }
|
||||
?: return null
|
||||
val expectedMatched = expectedAmountCents != null && amountCents == expectedAmountCents
|
||||
val amountCents = resultAmount ?: expectedCents?.takeIf { canUseExpectedAmount }
|
||||
?: return PaymentParseOutcome(
|
||||
null,
|
||||
resultCandidates.size,
|
||||
resultAmount,
|
||||
null,
|
||||
if (expectedCents == null) "expected_amount_missing" else "missing_amount",
|
||||
)
|
||||
val expectedMatched = if (expectedCents != null && resultAmount != null) {
|
||||
resultAmount == expectedCents
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val conflict = expectedMatched == false
|
||||
val expectedStrong = expectedEvidence?.isStrong == true
|
||||
val evidenceHigh = trustedFlow && submittedFlow && resultTransitionObserved &&
|
||||
expectedMatched && expectedType == direction
|
||||
expectedType == direction && !conflict &&
|
||||
(resultAmount != null || expectedStrong)
|
||||
val kind = flowKind ?: recognitionKind(text, direction)
|
||||
val merchant = extractMerchant(text)
|
||||
val orderId = extractOrderId(text)
|
||||
val transferDirection = direction.takeIf { kind == "transfer" }?.let {
|
||||
if (it == "income") "in" else "out"
|
||||
}
|
||||
return PaymentSignal(
|
||||
val issueReason = when {
|
||||
conflict -> "result_amount_conflict"
|
||||
resultAmount == null && !expectedStrong -> "weak_expected_fallback"
|
||||
resultAmount == null -> "expected_amount_fallback"
|
||||
else -> null
|
||||
}
|
||||
val signal = PaymentSignal(
|
||||
packageName = packageName,
|
||||
channel = "accessibility",
|
||||
amountCents = amountCents,
|
||||
@@ -222,6 +322,17 @@ object PaymentParser {
|
||||
),
|
||||
transferDirection = transferDirection,
|
||||
counterparty = merchant.takeIf { kind == "transfer" },
|
||||
amountEvidenceStrength = if (resultAmount != null || expectedStrong) "strong" else "weak",
|
||||
amountCandidateCount = resultCandidates.size,
|
||||
expectedAmountMatched = expectedMatched,
|
||||
amountIssueReason = issueReason,
|
||||
)
|
||||
return PaymentParseOutcome(
|
||||
signal,
|
||||
resultCandidates.size,
|
||||
resultAmount,
|
||||
expectedMatched,
|
||||
issueReason,
|
||||
)
|
||||
}
|
||||
fun fromNotification(notification: StatusBarNotification): PaymentSignal? {
|
||||
@@ -313,8 +424,12 @@ object PaymentParser {
|
||||
}
|
||||
|
||||
fun uniqueAmountCents(value: String): Long? {
|
||||
return amountCandidateCents(value).singleOrNull()
|
||||
}
|
||||
|
||||
fun amountCandidateCents(value: String): List<Long> {
|
||||
val normalized = normalize(value)
|
||||
val cents = buildList {
|
||||
return buildList {
|
||||
amountPatterns.forEach { pattern ->
|
||||
pattern.findAll(normalized).forEach { match ->
|
||||
match.groupValues.getOrNull(1)?.toDoubleOrNull()?.let { amount ->
|
||||
@@ -332,13 +447,63 @@ object PaymentParser {
|
||||
}
|
||||
}
|
||||
}.distinct()
|
||||
return cents.singleOrNull()
|
||||
}
|
||||
|
||||
fun expectedAmountEvidence(
|
||||
pageText: String,
|
||||
eventText: String,
|
||||
flowSessionId: String,
|
||||
windowId: Int,
|
||||
capturedAtEpochMs: Long,
|
||||
): ExpectedAmountEvidence? {
|
||||
val pageFingerprint = sha256(normalize(pageText))
|
||||
val labeled = LABELED_AMOUNT.findAll(normalize(pageText))
|
||||
.mapNotNull { match ->
|
||||
match.groupValues.getOrNull(1)?.toDoubleOrNull()?.let {
|
||||
(it * 100).roundToLong()
|
||||
}
|
||||
}
|
||||
.filter { it > 0 }
|
||||
.distinct()
|
||||
.toList()
|
||||
if (labeled.size == 1) {
|
||||
return ExpectedAmountEvidence(
|
||||
labeled.single(),
|
||||
flowSessionId,
|
||||
windowId,
|
||||
pageFingerprint,
|
||||
capturedAtEpochMs,
|
||||
labeled.size,
|
||||
"labeled_page",
|
||||
"strong",
|
||||
)
|
||||
}
|
||||
val eventCandidates = amountCandidateCents(eventText)
|
||||
if (eventCandidates.size != 1) return null
|
||||
val explicitEvent = EXPLICIT_AMOUNT.containsMatchIn(normalize(eventText))
|
||||
return ExpectedAmountEvidence(
|
||||
eventCandidates.single(),
|
||||
flowSessionId,
|
||||
windowId,
|
||||
pageFingerprint,
|
||||
capturedAtEpochMs,
|
||||
eventCandidates.size,
|
||||
"payment_event",
|
||||
if (explicitEvent) "strong" else "weak",
|
||||
)
|
||||
}
|
||||
|
||||
fun isPaymentInputPage(value: String): Boolean {
|
||||
val normalized = normalize(value)
|
||||
val transferInputComposite = "转账给" in normalized && (
|
||||
"转账全额" in normalized ||
|
||||
"添加转账说明" in normalized ||
|
||||
"请输入金额" in normalized ||
|
||||
Regex("""[¥¥]\s*0(?:\.0{1,2})?""").containsMatchIn(normalized)
|
||||
)
|
||||
return paymentInputWords.any(normalized::contains) ||
|
||||
redPacketSendActionWords.any(normalized::contains)
|
||||
redPacketSendActionWords.any(normalized::contains) ||
|
||||
transferInputComposite
|
||||
}
|
||||
|
||||
fun detectFlowKind(value: String): String? {
|
||||
@@ -408,12 +573,17 @@ object PaymentParser {
|
||||
val normalized = normalize(value)
|
||||
amountPatterns.firstNotNullOfOrNull { pattern ->
|
||||
pattern.find(normalized)?.groupValues?.getOrNull(1)?.toDoubleOrNull()
|
||||
}?.let { return it }
|
||||
}?.takeIf { it.isFinite() && it > 0.0 }?.let { return it }
|
||||
val match = STANDALONE_AMOUNT.matchEntire(normalized) ?: return null
|
||||
val number = match.groupValues[2]
|
||||
val hasCurrencyOrUnit = match.groupValues[1].isNotEmpty() ||
|
||||
match.groupValues[3].isNotEmpty()
|
||||
return if (!hasCurrencyOrUnit && !number.contains('.')) null else number.toDoubleOrNull()
|
||||
val amount = if (!hasCurrencyOrUnit && !number.contains('.')) {
|
||||
null
|
||||
} else {
|
||||
number.toDoubleOrNull()
|
||||
}
|
||||
return amount?.takeIf { it.isFinite() && it > 0.0 }
|
||||
}
|
||||
|
||||
fun extractOrderId(value: String): String? =
|
||||
@@ -548,6 +718,10 @@ object PaymentParser {
|
||||
private val STANDALONE_AMOUNT = Regex(
|
||||
"""^\s*([¥¥]?)\s*([0-9]{1,8}(?:\.[0-9]{1,2})?)\s*(元?)\s*$""",
|
||||
)
|
||||
private val LABELED_AMOUNT = Regex(
|
||||
"""(?:实付|付款金额|支付金额|转账金额|红包金额|收款金额|到账金额|交易金额)[:: \t]*(?:\r?\n[:: \t]*)?[¥¥]?[ \t]*([0-9]+(?:\.[0-9]{1,2})?)""",
|
||||
)
|
||||
private val EXPLICIT_AMOUNT = Regex("""[¥¥]\s*[0-9]+(?:\.[0-9]{1,2})?|[0-9]+\.[0-9]{1,2}""")
|
||||
private const val MAX_SUCCESS_HEADING_CHARS = 48
|
||||
private const val MAX_PAYMENT_SCAN_LINES = 48
|
||||
private const val MAX_HISTORY_TITLE_LINES = 3
|
||||
|
||||
@@ -13,6 +13,9 @@ class RecognitionBridgeProvider : ContentProvider() {
|
||||
private val captureResults = ConcurrentHashMap<String, CaptureResult>()
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
context?.let {
|
||||
RecognitionConnectionStore.markRecognitionProcessStarted(it, PROCESS_STARTED_AT)
|
||||
}
|
||||
context?.let(RecognitionCoordinator::get)
|
||||
return true
|
||||
}
|
||||
@@ -22,20 +25,40 @@ class RecognitionBridgeProvider : ContentProvider() {
|
||||
return when (method) {
|
||||
METHOD_STATUS -> Bundle().apply {
|
||||
putBoolean("accessibilityConnected", ScreenshotAccessibilityService.isConnected)
|
||||
putLong(
|
||||
"accessibilityLastConnectedAt",
|
||||
RecognitionConnectionStore.lastConnectedAt(appContext),
|
||||
)
|
||||
putLong(
|
||||
"accessibilityLastDisconnectedAt",
|
||||
RecognitionConnectionStore.lastDisconnectedAt(appContext),
|
||||
)
|
||||
putLong("recognitionProcessStartedAt", PROCESS_STARTED_AT)
|
||||
putBoolean("notificationConnected", PaymentNotificationListenerService.isConnected)
|
||||
putBoolean(
|
||||
"keepAliveExpected",
|
||||
RecognitionKeepAliveService.isExpected(appContext),
|
||||
)
|
||||
putBoolean("keepAliveRunning", RecognitionKeepAliveService.isRunning)
|
||||
putString("keepAliveError", RecognitionKeepAliveService.lastStartError)
|
||||
putString("settings", RecognitionSettings.statusJson(appContext))
|
||||
putString("latestStatus", RecognitionCoordinator.get(appContext).latestStatus())
|
||||
putString("latestStatus", RecognitionCoordinator.get(appContext).latestStatus())
|
||||
putString("latestDiagnostic", RecognitionDiagnostics.latest(appContext))
|
||||
}
|
||||
METHOD_SET_TOGGLE -> Bundle().apply {
|
||||
putBoolean(
|
||||
"success",
|
||||
RecognitionSettings.setToggle(
|
||||
appContext,
|
||||
extras?.getString("key").orEmpty(),
|
||||
extras?.getBoolean("enabled") ?: false,
|
||||
),
|
||||
METHOD_SET_TOGGLE -> {
|
||||
val changed = RecognitionSettings.setToggle(
|
||||
appContext,
|
||||
extras?.getString("key").orEmpty(),
|
||||
extras?.getBoolean("enabled") ?: false,
|
||||
)
|
||||
if (changed) RecognitionKeepAliveService.reconcile(appContext)
|
||||
Bundle().apply {
|
||||
putBoolean("success", changed)
|
||||
putBoolean(
|
||||
"keepAliveExpected",
|
||||
RecognitionKeepAliveService.isExpected(appContext),
|
||||
)
|
||||
}
|
||||
}
|
||||
METHOD_CLEAR_DIAGNOSTIC -> Bundle().apply {
|
||||
RecognitionDiagnostics.clear(appContext)
|
||||
@@ -51,6 +74,9 @@ class RecognitionBridgeProvider : ContentProvider() {
|
||||
)
|
||||
putBoolean("success", true)
|
||||
}
|
||||
METHOD_ENSURE_KEEP_ALIVE -> Bundle().apply {
|
||||
putBoolean("success", RecognitionKeepAliveService.reconcile(appContext))
|
||||
}
|
||||
METHOD_REQUEST_SCREENSHOT -> requestScreenshot(extras)
|
||||
METHOD_SCREENSHOT_RESULT -> takeScreenshotResult(arg)
|
||||
METHOD_DRAIN -> Bundle().apply {
|
||||
@@ -143,9 +169,11 @@ class RecognitionBridgeProvider : ContentProvider() {
|
||||
private data class CaptureResult(val path: String?, val error: String?)
|
||||
|
||||
companion object {
|
||||
private val PROCESS_STARTED_AT = System.currentTimeMillis()
|
||||
const val METHOD_STATUS = "status"
|
||||
const val METHOD_SET_TOGGLE = "setToggle"
|
||||
const val METHOD_SET_RUNTIME = "setRuntime"
|
||||
const val METHOD_SET_RUNTIME = "setRuntime"
|
||||
const val METHOD_ENSURE_KEEP_ALIVE = "ensureKeepAlive"
|
||||
const val METHOD_CLEAR_DIAGNOSTIC = "clearDiagnostic"
|
||||
const val METHOD_REQUEST_SCREENSHOT = "requestScreenshot"
|
||||
const val METHOD_SCREENSHOT_RESULT = "screenshotResult"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.nx.miaoji
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object RecognitionConnectionStore {
|
||||
private const val PREFS = "recognition_connection"
|
||||
private const val KEY_LAST_CONNECTED_AT = "accessibility_last_connected_at"
|
||||
private const val KEY_LAST_DISCONNECTED_AT = "accessibility_last_disconnected_at"
|
||||
private const val KEY_LAST_TASK_REMOVED_AT = "recognition_last_task_removed_at"
|
||||
private const val KEY_PROCESS_STARTED_AT = "recognition_process_started_at"
|
||||
private const val KEY_RECENTS_PROTECTION_ACTIVE = "recents_protection_active"
|
||||
|
||||
fun markConnected(context: Context, at: Long = System.currentTimeMillis()) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(KEY_LAST_CONNECTED_AT, at)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun markDisconnected(context: Context, at: Long = System.currentTimeMillis()) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(KEY_LAST_DISCONNECTED_AT, at)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun lastConnectedAt(context: Context): Long =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getLong(KEY_LAST_CONNECTED_AT, 0L)
|
||||
|
||||
fun lastDisconnectedAt(context: Context): Long =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getLong(KEY_LAST_DISCONNECTED_AT, 0L)
|
||||
|
||||
fun markTaskRemoved(context: Context, at: Long = System.currentTimeMillis()) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(KEY_LAST_TASK_REMOVED_AT, at)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun lastTaskRemovedAt(context: Context): Long =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getLong(KEY_LAST_TASK_REMOVED_AT, 0L)
|
||||
|
||||
fun markRecognitionProcessStarted(
|
||||
context: Context,
|
||||
at: Long = System.currentTimeMillis(),
|
||||
) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(KEY_PROCESS_STARTED_AT, at)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun recognitionProcessStartedAt(context: Context): Long =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getLong(KEY_PROCESS_STARTED_AT, 0L)
|
||||
|
||||
fun setRecentsProtectionActive(context: Context, active: Boolean) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putBoolean(KEY_RECENTS_PROTECTION_ACTIVE, active)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun recentsProtectionActive(context: Context): Boolean =
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getBoolean(KEY_RECENTS_PROTECTION_ACTIVE, false)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.nx.miaoji
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class RecognitionKeepAliveReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
if (intent?.action !in setOf(
|
||||
Intent.ACTION_BOOT_COMPLETED,
|
||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||
)
|
||||
) return
|
||||
RecognitionKeepAliveService.ensureRunning(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.nx.miaoji
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
class RecognitionKeepAliveService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
isRunning = true
|
||||
lastStartError = null
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (!isExpected(this)) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
promoteToForeground()
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
isRunning = false
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
RecognitionConnectionStore.markTaskRemoved(this)
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun promoteToForeground() {
|
||||
val notification = buildNotification()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"智能识别后台保护",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "保持用户主动开启的微信、支付宝智能识别在后台运行"
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
setShowBadge(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val openIntent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
putExtra(MainActivity.EXTRA_ACTION, MainActivity.ACTION_OPEN_RECOGNITION_SETTINGS)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
NOTIFICATION_ID,
|
||||
openIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
@Suppress("DEPRECATION")
|
||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(this, CHANNEL_ID)
|
||||
} else {
|
||||
Notification.Builder(this)
|
||||
}
|
||||
return builder
|
||||
.setSmallIcon(android.R.drawable.ic_menu_view)
|
||||
.setContentTitle("智能识别运行中")
|
||||
.setContentText("正在等待微信、支付宝支付结果")
|
||||
.setContentIntent(pendingIntent)
|
||||
.setCategory(Notification.CATEGORY_SERVICE)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "recognition_keep_alive"
|
||||
private const val NOTIFICATION_ID = 2201
|
||||
|
||||
@Volatile
|
||||
var isRunning = false
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var lastStartError: String? = null
|
||||
private set
|
||||
|
||||
fun isExpected(context: Context): Boolean {
|
||||
val settings = RecognitionSettings.snapshot(context)
|
||||
return settings.accessibilityEvents ||
|
||||
settings.notificationEvents ||
|
||||
settings.aiScreenshot
|
||||
}
|
||||
|
||||
fun ensureRunning(context: Context): Boolean {
|
||||
val appContext = context.applicationContext
|
||||
if (!isExpected(appContext)) {
|
||||
stop(appContext)
|
||||
return true
|
||||
}
|
||||
if (!canShowNotification(appContext)) {
|
||||
lastStartError = "请允许通知权限后重新启动后台保护"
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
ContextCompat.startForegroundService(
|
||||
appContext,
|
||||
Intent(appContext, RecognitionKeepAliveService::class.java),
|
||||
)
|
||||
lastStartError = null
|
||||
true
|
||||
} catch (error: RuntimeException) {
|
||||
lastStartError = error.message ?: "系统限制了后台保护启动"
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun reconcile(context: Context): Boolean =
|
||||
if (isExpected(context)) ensureRunning(context) else {
|
||||
stop(context)
|
||||
true
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.applicationContext.stopService(
|
||||
Intent(context.applicationContext, RecognitionKeepAliveService::class.java),
|
||||
)
|
||||
isRunning = false
|
||||
lastStartError = null
|
||||
}
|
||||
|
||||
private fun canShowNotification(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,13 @@ data class PendingRecognitionBatch(
|
||||
val images: List<PendingBatchImage>,
|
||||
)
|
||||
|
||||
internal data class AmountMergeResult(
|
||||
val amountCents: Long,
|
||||
val source: String,
|
||||
val strength: String,
|
||||
val conflict: Boolean,
|
||||
)
|
||||
|
||||
class RecognitionStore(context: Context) :
|
||||
SQLiteOpenHelper(context, "recognition_queue.db", null, 3) {
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
@@ -178,20 +185,27 @@ class RecognitionStore(context: Context) :
|
||||
id = existing.id
|
||||
val mergedMask = existing.channelMask or channelBit
|
||||
val hasNonAiEvidence = mergedMask and channelBit("recognition_ai").inv() != 0
|
||||
val high = hasNonAiEvidence &&
|
||||
val mergedPayload = mergePayload(existing.payload, signal)
|
||||
val amountConflict = mergedPayload.optBoolean("amountConflict", false)
|
||||
val high = !amountConflict && hasNonAiEvidence &&
|
||||
(signalHigh ||
|
||||
(mergedMask and ACCESSIBILITY_NOTIFICATION_MASK) ==
|
||||
ACCESSIBILITY_NOTIFICATION_MASK ||
|
||||
existing.highConfidence)
|
||||
val mergedPayload = mergePayload(existing.payload, signal)
|
||||
writableDatabase.update(
|
||||
"candidates",
|
||||
ContentValues().apply {
|
||||
put("channel_mask", mergedMask)
|
||||
put("known_template", if (existing.knownTemplate || signal.knownTemplate) 1 else 0)
|
||||
put("amount_cents", kotlin.math.round(mergedPayload.optDouble("amount") * 100).toLong())
|
||||
put("direction", mergedPayload.optString("type", signal.type))
|
||||
put("high_confidence", if (high) 1 else 0)
|
||||
put("payload_encrypted", encryptPayload(mergedPayload))
|
||||
if (high && existing.state == "pending_confirm") put("state", "auto_ready")
|
||||
if (amountConflict) {
|
||||
put("state", "pending_confirm")
|
||||
} else if (high && existing.state == "pending_confirm") {
|
||||
put("state", "auto_ready")
|
||||
}
|
||||
put("updated_at", now)
|
||||
put("available_at", now + MERGE_DELAY_MS)
|
||||
if (batchId != null) {
|
||||
@@ -525,9 +539,21 @@ class RecognitionStore(context: Context) :
|
||||
val original = JSONObject(row.payload.toString())
|
||||
val kind = action.optString("action")
|
||||
val payload = JSONObject(row.payload.toString())
|
||||
if (kind == "update") applyActionFields(payload, action)
|
||||
if (kind == "update") {
|
||||
applyActionFields(payload, action)
|
||||
if (payload.optDouble("amount") != original.optDouble("amount")) {
|
||||
payload
|
||||
.put("amountSource", "ai_batch")
|
||||
.put("amountEvidenceStrength", "strong")
|
||||
.put("amountConflict", false)
|
||||
.remove("conflictingAmount")
|
||||
payload.remove("conflictingAmountSource")
|
||||
payload.remove("amountIssueReason")
|
||||
}
|
||||
}
|
||||
payload.put("sourceOverride", "recognition_ai")
|
||||
val reason = action.optString("reason", "AI 对账")
|
||||
val amountConflict = payload.optBoolean("amountConflict", false)
|
||||
writableDatabase.update(
|
||||
"candidates",
|
||||
ContentValues().apply {
|
||||
@@ -540,8 +566,15 @@ class RecognitionStore(context: Context) :
|
||||
put("channel_mask", row.channelMask or channelBit("recognition_ai"))
|
||||
put("ai_action", kind)
|
||||
put("ai_reason", reason.take(80))
|
||||
put("state", if (kind == "drop") "ai_dropped" else "auto_ready")
|
||||
put("high_confidence", 1)
|
||||
put(
|
||||
"state",
|
||||
when {
|
||||
kind == "drop" -> "ai_dropped"
|
||||
amountConflict -> "pending_confirm"
|
||||
else -> "auto_ready"
|
||||
},
|
||||
)
|
||||
put("high_confidence", if (amountConflict) 0 else 1)
|
||||
put("updated_at", now)
|
||||
},
|
||||
"id = ? AND batch_id = ?",
|
||||
@@ -631,14 +664,17 @@ class RecognitionStore(context: Context) :
|
||||
// The server guarantees one action per candidate. Preserve anything omitted
|
||||
// by a malformed response instead of silently losing a payment.
|
||||
writableDatabase.rawQuery(
|
||||
"SELECT id FROM candidates WHERE batch_id = ? AND state = 'batch_collecting'",
|
||||
"SELECT id, payload_encrypted FROM candidates WHERE batch_id = ? AND state = 'batch_collecting'",
|
||||
arrayOf(batchId),
|
||||
).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val amountConflict = decryptPayload(cursor.getString(1))
|
||||
?.optBoolean("amountConflict", false) == true
|
||||
writableDatabase.update(
|
||||
"candidates",
|
||||
ContentValues().apply {
|
||||
put("state", "auto_ready")
|
||||
put("state", if (amountConflict) "pending_confirm" else "auto_ready")
|
||||
put("high_confidence", if (amountConflict) 0 else 1)
|
||||
put("ai_action", "keep")
|
||||
put("ai_reason", "AI 未返回该候选,已保留本地结果")
|
||||
put("updated_at", now)
|
||||
@@ -1011,6 +1047,10 @@ class RecognitionStore(context: Context) :
|
||||
.put("recognitionKind", signal.recognitionKind)
|
||||
.put("categoryHint", signal.categoryHint)
|
||||
.put("amountSource", signal.amountSource)
|
||||
.put("amountEvidenceStrength", signal.amountEvidenceStrength)
|
||||
.put("amountCandidateCount", signal.amountCandidateCount)
|
||||
.put("expectedAmountMatched", signal.expectedAmountMatched)
|
||||
.put("amountIssueReason", signal.amountIssueReason)
|
||||
.put("resultFingerprint", signal.resultFingerprint)
|
||||
.put("identityConfidence", signal.identityConfidence)
|
||||
.put("transferDirection", signal.transferDirection)
|
||||
@@ -1043,7 +1083,36 @@ class RecognitionStore(context: Context) :
|
||||
if (existing.isNull("sourceText") && signal.sourceText != null) existing.put("sourceText", signal.sourceText)
|
||||
if (existing.optString("recognitionKind").isBlank()) existing.put("recognitionKind", signal.recognitionKind)
|
||||
if (existing.isNull("categoryHint") && signal.categoryHint != null) existing.put("categoryHint", signal.categoryHint)
|
||||
if (existing.optString("amountSource").isBlank()) existing.put("amountSource", signal.amountSource)
|
||||
val existingAmountCents = kotlin.math.round(existing.optDouble("amount") * 100).toLong()
|
||||
val amountMerge = mergeAmountEvidence(
|
||||
existingAmountCents = existingAmountCents,
|
||||
existingSource = existing.optString("amountSource", "result"),
|
||||
existingStrength = existing.optString("amountEvidenceStrength", "strong"),
|
||||
incoming = signal,
|
||||
)
|
||||
val amountConflict = existing.optBoolean("amountConflict", false) ||
|
||||
amountMerge.conflict
|
||||
existing
|
||||
.put("amount", amountMerge.amountCents / 100.0)
|
||||
.put("amountSource", amountMerge.source)
|
||||
.put("amountEvidenceStrength", amountMerge.strength)
|
||||
.put("amountConflict", amountConflict)
|
||||
.put("amountCandidateCount", maxOf(
|
||||
existing.optInt("amountCandidateCount", 0),
|
||||
signal.amountCandidateCount,
|
||||
))
|
||||
if (amountMerge.conflict) {
|
||||
existing
|
||||
.put("conflictingAmount", signal.amountCents / 100.0)
|
||||
.put("conflictingAmountSource", signal.amountSource)
|
||||
}
|
||||
if (amountConflict) {
|
||||
existing.put("amountIssueReason", "result_amount_conflict")
|
||||
} else if (signal.amountIssueReason != null) {
|
||||
existing.put("amountIssueReason", signal.amountIssueReason)
|
||||
} else if (amountMerge.source == "result") {
|
||||
existing.remove("amountIssueReason")
|
||||
}
|
||||
if (existing.isNull("resultFingerprint") && signal.resultFingerprint != null) {
|
||||
existing.put("resultFingerprint", signal.resultFingerprint)
|
||||
}
|
||||
@@ -1111,6 +1180,81 @@ class RecognitionStore(context: Context) :
|
||||
private const val MAX_BATCH_ITEMS = 10
|
||||
private const val MAX_BATCH_IMAGE_BYTES = 1024 * 1024
|
||||
|
||||
internal fun mergeAmountEvidence(
|
||||
existingAmountCents: Long,
|
||||
existingSource: String,
|
||||
existingStrength: String,
|
||||
incoming: PaymentSignal,
|
||||
): AmountMergeResult {
|
||||
if (existingAmountCents == incoming.amountCents) {
|
||||
val incomingRank = amountEvidenceRank(
|
||||
incoming.amountSource,
|
||||
incoming.amountEvidenceStrength,
|
||||
)
|
||||
val existingRank = amountEvidenceRank(existingSource, existingStrength)
|
||||
return if (incomingRank > existingRank) {
|
||||
AmountMergeResult(
|
||||
incoming.amountCents,
|
||||
incoming.amountSource,
|
||||
incoming.amountEvidenceStrength,
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
AmountMergeResult(
|
||||
existingAmountCents,
|
||||
existingSource,
|
||||
existingStrength,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val incomingRank = amountEvidenceRank(
|
||||
incoming.amountSource,
|
||||
incoming.amountEvidenceStrength,
|
||||
)
|
||||
val existingRank = amountEvidenceRank(existingSource, existingStrength)
|
||||
val bothStrong = existingStrength == "strong" &&
|
||||
incoming.amountEvidenceStrength == "strong"
|
||||
val independentConflict = bothStrong &&
|
||||
(existingSource == "result" || incoming.amountSource == "result")
|
||||
if (independentConflict) {
|
||||
return AmountMergeResult(
|
||||
existingAmountCents,
|
||||
existingSource,
|
||||
existingStrength,
|
||||
true,
|
||||
)
|
||||
}
|
||||
val useIncoming = when {
|
||||
incoming.amountSource == "result" && existingSource != "result" -> true
|
||||
existingSource == "result" && incoming.amountSource != "result" -> false
|
||||
else -> incomingRank > existingRank
|
||||
}
|
||||
return if (useIncoming) {
|
||||
AmountMergeResult(
|
||||
incoming.amountCents,
|
||||
incoming.amountSource,
|
||||
incoming.amountEvidenceStrength,
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
AmountMergeResult(
|
||||
existingAmountCents,
|
||||
existingSource,
|
||||
existingStrength,
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun amountEvidenceRank(source: String, strength: String): Int = when {
|
||||
source == "result" && strength == "strong" -> 4
|
||||
source == "result" -> 3
|
||||
strength == "strong" -> 2
|
||||
else -> 1
|
||||
}
|
||||
|
||||
internal fun clientRequestIdFor(signal: PaymentSignal): String {
|
||||
val basis = when {
|
||||
!signal.orderId.isNullOrBlank() ->
|
||||
|
||||
+210
-47
@@ -30,6 +30,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
private var lastVisualCaptureAt = 0L
|
||||
private var paymentFlow: PaymentFlow? = null
|
||||
private var pendingVisualCapture: Runnable? = null
|
||||
private var pendingVisualCaptureAt = 0L
|
||||
private var ocrInProgress = false
|
||||
private var visualOperationId: String? = null
|
||||
private var visualTimeout: Runnable? = null
|
||||
@@ -47,7 +48,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
var kind: String,
|
||||
var originWindowId: Int = windowId,
|
||||
var originPageHash: String? = null,
|
||||
var expectedAmountCents: Long? = null,
|
||||
var expectedAmountEvidence: ExpectedAmountEvidence? = null,
|
||||
var expectedType: String? = null,
|
||||
var committedAt: Long? = null,
|
||||
var completedAt: Long? = null,
|
||||
@@ -58,6 +59,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
var resultSurfaceExited: Boolean = false,
|
||||
var retryCount: Int = 0,
|
||||
var probeCount: Int = 0,
|
||||
var trackingTimedOut: Boolean = false,
|
||||
)
|
||||
|
||||
private data class CaptureRequest(
|
||||
@@ -78,6 +80,9 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
activeInstance = this
|
||||
isConnected = true
|
||||
lastConnectedAt = System.currentTimeMillis()
|
||||
RecognitionConnectionStore.markConnected(this, lastConnectedAt)
|
||||
RecognitionKeepAliveService.ensureRunning(this)
|
||||
Log.i(TAG, "Accessibility recognition service connected")
|
||||
}
|
||||
|
||||
@@ -188,7 +193,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
)
|
||||
return
|
||||
}
|
||||
val observed = PaymentParser.fromAccessibility(
|
||||
val observedOutcome = PaymentParser.fromAccessibilityOutcome(
|
||||
packageName = recognizedPackage,
|
||||
text = combined,
|
||||
eventTime = currentEvent.eventTime,
|
||||
@@ -196,6 +201,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
expectedType = status.direction ?: existingFlow.expectedType,
|
||||
flowKind = currentKind,
|
||||
)
|
||||
val observed = observedOutcome.signal
|
||||
val resultChanged = observed?.resultFingerprint != null &&
|
||||
existingFlow.resultFingerprint != null &&
|
||||
observed.resultFingerprint != existingFlow.resultFingerprint
|
||||
@@ -229,6 +235,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
nextFlow,
|
||||
page.nodeCount,
|
||||
"tree",
|
||||
parseOutcome = observedOutcome,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -267,13 +274,14 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
currentEvent.windowId,
|
||||
now,
|
||||
forceNew = existingFlow?.completed == true ||
|
||||
existingFlow?.committedAt != null ||
|
||||
existingFlow?.packageName != recognizedPackage,
|
||||
kind = inferredKind,
|
||||
)
|
||||
else -> existingFlow?.takeIf { !it.completed }
|
||||
}
|
||||
if (armedFlow != null) {
|
||||
updateFlowEvidence(armedFlow, combined)
|
||||
updateFlowEvidence(armedFlow, combined, eventText, now)
|
||||
if (clickedPaymentAction) {
|
||||
armedFlow.committedAt = armedFlow.committedAt ?: now
|
||||
armedFlow.expectedType = armedFlow.expectedType ?: "expense"
|
||||
@@ -284,19 +292,28 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
|
||||
val directFlow = paymentFlow?.takeIf { !it.completed }
|
||||
val directSignal = if (combined.isBlank()) null else PaymentParser.fromAccessibility(
|
||||
packageName = recognizedPackage,
|
||||
text = combined,
|
||||
eventTime = currentEvent.eventTime,
|
||||
windowId = currentEvent.windowId,
|
||||
flowSessionId = directFlow?.id,
|
||||
trustedFlow = directFlow?.trusted == true,
|
||||
expectedAmountCents = directFlow?.expectedAmountCents,
|
||||
expectedType = directFlow?.expectedType,
|
||||
resultTransitionObserved = directFlow?.resultTransitionObserved == true,
|
||||
submittedFlow = directFlow?.committedAt != null,
|
||||
flowKind = directFlow?.kind,
|
||||
)
|
||||
val directOutcome = if (combined.isBlank()) null else {
|
||||
PaymentParser.fromAccessibilityOutcome(
|
||||
packageName = recognizedPackage,
|
||||
text = combined,
|
||||
eventTime = currentEvent.eventTime,
|
||||
windowId = currentEvent.windowId,
|
||||
flowSessionId = directFlow?.id,
|
||||
trustedFlow = directFlow?.trusted == true,
|
||||
expectedAmountEvidence = directFlow?.expectedAmountEvidence?.takeIf {
|
||||
it.belongsTo(
|
||||
directFlow.id,
|
||||
directFlow.startedAt,
|
||||
directFlow.committedAt,
|
||||
)
|
||||
},
|
||||
expectedType = directFlow?.expectedType,
|
||||
resultTransitionObserved = directFlow?.resultTransitionObserved == true,
|
||||
submittedFlow = directFlow?.committedAt != null,
|
||||
flowKind = directFlow?.kind,
|
||||
)
|
||||
}
|
||||
val directSignal = directOutcome?.signal
|
||||
if (directSignal != null) {
|
||||
val flow = directFlow ?: armPaymentFlow(
|
||||
recognizedPackage,
|
||||
@@ -313,6 +330,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
flow,
|
||||
page.nodeCount,
|
||||
"tree",
|
||||
parseOutcome = directOutcome,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -350,9 +368,12 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
private fun disconnect() {
|
||||
if (activeInstance === this) activeInstance = null
|
||||
isConnected = false
|
||||
lastDisconnectedAt = System.currentTimeMillis()
|
||||
RecognitionConnectionStore.markDisconnected(this, lastDisconnectedAt)
|
||||
pendingRetry = null
|
||||
retryTimeout = null
|
||||
pendingVisualCapture = null
|
||||
pendingVisualCaptureAt = 0L
|
||||
visualTimeout?.let(handler::removeCallbacks)
|
||||
visualTimeout = null
|
||||
batchCaptureTimeout?.let(handler::removeCallbacks)
|
||||
@@ -411,13 +432,35 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
).also { paymentFlow = it }
|
||||
}
|
||||
|
||||
private fun updateFlowEvidence(flow: PaymentFlow, text: String) {
|
||||
if (text.isBlank() || flow.completed || flow.resultTransitionObserved) return
|
||||
private fun updateFlowEvidence(
|
||||
flow: PaymentFlow,
|
||||
text: String,
|
||||
eventText: String,
|
||||
now: Long,
|
||||
) {
|
||||
if (text.isBlank() || flow.completed || flow.committedAt != null ||
|
||||
flow.resultTransitionObserved
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (PaymentParser.detectStatus(text).strength != PaymentStatusStrength.NONE) return
|
||||
val pageHash = PaymentParser.sha256(text)
|
||||
flow.originPageHash = flow.originPageHash ?: pageHash
|
||||
flow.expectedAmountCents = flow.expectedAmountCents
|
||||
?: PaymentParser.uniqueAmountCents(text)
|
||||
val observed = PaymentParser.expectedAmountEvidence(
|
||||
pageText = text,
|
||||
eventText = eventText,
|
||||
flowSessionId = flow.id,
|
||||
windowId = flow.windowId,
|
||||
capturedAtEpochMs = now,
|
||||
)
|
||||
if (observed != null) {
|
||||
val existing = flow.expectedAmountEvidence
|
||||
if (existing == null || observed.isStrong && !existing.isStrong ||
|
||||
observed.source == existing.source
|
||||
) {
|
||||
flow.expectedAmountEvidence = observed
|
||||
}
|
||||
}
|
||||
flow.expectedType = flow.expectedType
|
||||
?: PaymentParser.inferContextDirection(text)
|
||||
PaymentParser.detectFlowKind(text)?.let { detected ->
|
||||
@@ -455,9 +498,13 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
private fun expirePaymentFlow(now: Long) {
|
||||
val flow = paymentFlow ?: return
|
||||
if (now - flow.startedAt > PAYMENT_FLOW_TTL_MS) {
|
||||
if (flow.committedAt != null && !flow.completed && !flow.trackingTimedOut) {
|
||||
recordResultPageTimeout(flow, 0)
|
||||
}
|
||||
paymentFlow = null
|
||||
pendingVisualCapture?.let(handler::removeCallbacks)
|
||||
pendingVisualCapture = null
|
||||
pendingVisualCaptureAt = 0L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +515,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
stage: String,
|
||||
recordDiagnostic: Boolean = true,
|
||||
evidenceImage: ByteArray? = null,
|
||||
parseOutcome: PaymentParseOutcome? = null,
|
||||
) {
|
||||
if (flow.completed) {
|
||||
evidenceImage?.fill(0)
|
||||
@@ -476,6 +524,9 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
flow.completed = true
|
||||
flow.completedAt = System.currentTimeMillis()
|
||||
flow.resultFingerprint = signal.resultFingerprint
|
||||
pendingVisualCapture?.let(handler::removeCallbacks)
|
||||
pendingVisualCapture = null
|
||||
pendingVisualCaptureAt = 0L
|
||||
val coordinator = RecognitionCoordinator.get(this)
|
||||
val settings = RecognitionSettings.snapshot(this)
|
||||
val batchEnabled = settings.aiScreenshot && settings.aiAllowed && settings.hasAccount
|
||||
@@ -503,11 +554,13 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
"confirm"
|
||||
},
|
||||
nodeCount = nodeCount,
|
||||
amountCandidates = 1,
|
||||
reason = if (batchEnabled) "queued_for_ai" else "success",
|
||||
expectedAmountMatched = flow.expectedAmountCents?.let {
|
||||
it == signal.amountCents
|
||||
},
|
||||
amountCandidates = parseOutcome?.amountCandidateCount
|
||||
?: signal.amountCandidateCount,
|
||||
reason = signal.amountIssueReason
|
||||
?: parseOutcome?.reason
|
||||
?: if (batchEnabled) "queued_for_ai" else "success",
|
||||
expectedAmountMatched = parseOutcome?.expectedAmountMatched
|
||||
?: signal.expectedAmountMatched,
|
||||
resultTransitionObserved = flow.resultTransitionObserved,
|
||||
recognitionKind = signal.recognitionKind,
|
||||
amountSource = signal.amountSource,
|
||||
@@ -524,22 +577,58 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
|
||||
flow.completed ||
|
||||
flow.probeCount >= MAX_VISUAL_PROBES ||
|
||||
ocrInProgress
|
||||
flow.trackingTimedOut ||
|
||||
flow.probeCount >= MAX_VISUAL_PROBES
|
||||
) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - flow.startedAt >= PAYMENT_FLOW_TTL_MS) {
|
||||
recordResultPageTimeout(flow, nodeCount)
|
||||
return
|
||||
}
|
||||
val throttleWait = (
|
||||
VISUAL_CAPTURE_THROTTLE_MS - (now - lastVisualCaptureAt)
|
||||
).coerceAtLeast(0L)
|
||||
val targetAt = now + maxOf(delayMs, throttleWait)
|
||||
if (pendingVisualCapture != null &&
|
||||
pendingVisualCaptureAt > 0L &&
|
||||
pendingVisualCaptureAt <= targetAt
|
||||
) return
|
||||
pendingVisualCapture?.let(handler::removeCallbacks)
|
||||
pendingVisualCapture = Runnable {
|
||||
pendingVisualCapture = null
|
||||
pendingVisualCaptureAt = 0L
|
||||
val current = paymentFlow
|
||||
if (current?.id != flow.id || current.completed ||
|
||||
lastPackageName != flow.packageName
|
||||
) return@Runnable
|
||||
if (current?.id != flow.id || current.completed || current.trackingTimedOut) {
|
||||
return@Runnable
|
||||
}
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (currentTime - current.startedAt >= PAYMENT_FLOW_TTL_MS) {
|
||||
recordResultPageTimeout(current, nodeCount)
|
||||
return@Runnable
|
||||
}
|
||||
if (lastPackageName != current.packageName) {
|
||||
scheduleVisualRecognition(
|
||||
current,
|
||||
reason = "waiting_for_payment_app",
|
||||
nodeCount = nodeCount,
|
||||
delayMs = PAYMENT_APP_RECHECK_MS,
|
||||
)
|
||||
return@Runnable
|
||||
}
|
||||
if (captureInProgress || ocrInProgress) {
|
||||
scheduleVisualRecognition(
|
||||
current,
|
||||
reason = "recognition_busy",
|
||||
nodeCount = nodeCount,
|
||||
delayMs = BUSY_RECHECK_MS,
|
||||
)
|
||||
return@Runnable
|
||||
}
|
||||
captureForLocalOcr(current, reason, nodeCount)
|
||||
}.also { handler.postDelayed(it, maxOf(delayMs, throttleWait)) }
|
||||
}.also {
|
||||
pendingVisualCaptureAt = targetAt
|
||||
handler.postDelayed(it, (targetAt - now).coerceAtLeast(0L))
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureForLocalOcr(flow: PaymentFlow, reason: String, nodeCount: Int) {
|
||||
@@ -659,10 +748,7 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
val recognitionSettings = RecognitionSettings.snapshot(
|
||||
this@ScreenshotAccessibilityService,
|
||||
)
|
||||
val visualTransitionObserved = flow.resultTransitionObserved ||
|
||||
flow.committedAt?.let {
|
||||
capturedAt - it >= VISUAL_STABILITY_DELAY_MS
|
||||
} == true
|
||||
val visualTransitionObserved = flow.resultTransitionObserved
|
||||
runCatching {
|
||||
LocalPaymentOcr.analyze(
|
||||
context = this@ScreenshotAccessibilityService,
|
||||
@@ -671,7 +757,9 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
flowSessionId = flow.id,
|
||||
trustedFlow = flow.trusted,
|
||||
capturedAt = capturedAt,
|
||||
expectedAmountCents = flow.expectedAmountCents,
|
||||
expectedAmountEvidence = flow.expectedAmountEvidence?.takeIf {
|
||||
it.belongsTo(flow.id, flow.startedAt, flow.committedAt)
|
||||
},
|
||||
expectedType = flow.expectedType,
|
||||
resultTransitionObserved = visualTransitionObserved,
|
||||
submittedFlow = flow.committedAt != null,
|
||||
@@ -773,15 +861,30 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
nodeCount: Int,
|
||||
) {
|
||||
try {
|
||||
val retryable = outcome.signal == null && isRetryableOcrOutcome(outcome.reason)
|
||||
val shouldWait = retryable && shouldScheduleResultProbe(
|
||||
probeCount = flow.probeCount,
|
||||
completed = flow.completed,
|
||||
expired = System.currentTimeMillis() - flow.startedAt >= PAYMENT_FLOW_TTL_MS,
|
||||
)
|
||||
val diagnosticReason = if (retryable && !shouldWait) {
|
||||
"result_page_timeout"
|
||||
} else {
|
||||
outcome.reason
|
||||
}
|
||||
RecognitionDiagnostics.record(
|
||||
this,
|
||||
flow.packageName,
|
||||
stage = "ocr",
|
||||
result = if (outcome.signal != null) "matched" else "rejected",
|
||||
result = when {
|
||||
outcome.signal != null -> "matched"
|
||||
shouldWait -> "waiting"
|
||||
else -> "rejected"
|
||||
},
|
||||
nodeCount = nodeCount,
|
||||
ocrMs = outcome.latencyMs,
|
||||
amountCandidates = outcome.amountCandidateCount,
|
||||
reason = outcome.reason,
|
||||
reason = diagnosticReason,
|
||||
statusStrength = outcome.statusStrength,
|
||||
expectedAmountMatched = outcome.expectedAmountMatched,
|
||||
resultTransitionObserved = outcome.resultTransitionObserved,
|
||||
@@ -812,17 +915,17 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
)
|
||||
return
|
||||
}
|
||||
val retryable = isRetryableOcrOutcome(outcome.reason)
|
||||
if (retryable && flow.retryCount < MAX_VISUAL_RETRIES) {
|
||||
if (shouldWait) {
|
||||
flow.retryCount += 1
|
||||
scheduleVisualRecognition(
|
||||
flow,
|
||||
reason = "ocr_retry",
|
||||
reason = "result_page_follow_up",
|
||||
nodeCount = nodeCount,
|
||||
delayMs = VISUAL_RETRY_DELAY_MS,
|
||||
delayMs = resultProbeDelayAfterCapture(flow.probeCount),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (retryable) flow.trackingTimedOut = true
|
||||
if (outcome.sawSuccess) maybeUseAiFallback(flow, bitmap)
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
@@ -925,17 +1028,51 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
nodeCount = nodeCount,
|
||||
reason = reason,
|
||||
)
|
||||
if (reason == "interval_short" && flow.retryCount < MAX_VISUAL_RETRIES) {
|
||||
val retryable = reason in RESULT_TRACKING_CAPTURE_RETRY_REASONS
|
||||
if (retryable && shouldScheduleResultProbe(
|
||||
probeCount = flow.probeCount,
|
||||
completed = flow.completed,
|
||||
expired = System.currentTimeMillis() - flow.startedAt >= PAYMENT_FLOW_TTL_MS,
|
||||
)
|
||||
) {
|
||||
flow.retryCount += 1
|
||||
RecognitionDiagnostics.record(
|
||||
this,
|
||||
flow.packageName,
|
||||
stage = "capture",
|
||||
result = "waiting",
|
||||
nodeCount = nodeCount,
|
||||
reason = reason,
|
||||
)
|
||||
scheduleVisualRecognition(
|
||||
flow,
|
||||
reason = "capture_retry",
|
||||
nodeCount = nodeCount,
|
||||
delayMs = VISUAL_RETRY_DELAY_MS,
|
||||
delayMs = resultProbeDelayAfterCapture(flow.probeCount),
|
||||
)
|
||||
} else if (retryable) {
|
||||
recordResultPageTimeout(flow, nodeCount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordResultPageTimeout(flow: PaymentFlow, nodeCount: Int) {
|
||||
if (flow.completed || flow.trackingTimedOut) return
|
||||
flow.trackingTimedOut = true
|
||||
pendingVisualCapture?.let(handler::removeCallbacks)
|
||||
pendingVisualCapture = null
|
||||
pendingVisualCaptureAt = 0L
|
||||
RecognitionDiagnostics.record(
|
||||
this,
|
||||
flow.packageName,
|
||||
stage = "ocr",
|
||||
result = "rejected",
|
||||
nodeCount = nodeCount,
|
||||
reason = "result_page_timeout",
|
||||
recognitionKind = flow.kind,
|
||||
resultTransitionObserved = flow.resultTransitionObserved,
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldFallbackToDisplay(errorCode: Int): Boolean =
|
||||
errorCode == ERROR_TAKE_SCREENSHOT_INTERNAL_ERROR ||
|
||||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
|
||||
@@ -1174,14 +1311,14 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
private const val MIN_RESULT_TRANSITION_DELAY_MS = 250L
|
||||
private const val AMBIGUOUS_REPEAT_GAP_MS = 3_000L
|
||||
private const val VISUAL_STABILITY_DELAY_MS = 700L
|
||||
private const val VISUAL_RETRY_DELAY_MS = 850L
|
||||
private const val VISUAL_CAPTURE_THROTTLE_MS = 2_500L
|
||||
private const val PAYMENT_APP_RECHECK_MS = 1_000L
|
||||
private const val BUSY_RECHECK_MS = 500L
|
||||
private const val CAPTURE_CALLBACK_TIMEOUT_MS = 6_000L
|
||||
private const val OCR_CALLBACK_TIMEOUT_MS = 12_000L
|
||||
private const val STALE_BITMAP_RELEASE_DELAY_MS = 60_000L
|
||||
private const val WINDOW_CAPTURE_FALLBACK_DELAY_MS = 450L
|
||||
private const val MAX_VISUAL_RETRIES = 1
|
||||
private const val MAX_VISUAL_PROBES = 4
|
||||
private const val MAX_VISUAL_PROBES = 5
|
||||
private const val MAX_TREE_NODES = 160
|
||||
private const val MAX_CHILDREN_PER_NODE = 40
|
||||
private const val MAX_TEXT_CHARS = 8_000
|
||||
@@ -1198,6 +1335,13 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED,
|
||||
AccessibilityEvent.TYPE_VIEW_CLICKED,
|
||||
)
|
||||
private val RESULT_PROBE_DELAYS_MS = longArrayOf(2_500L, 5_000L, 10_000L, 20_000L)
|
||||
private val RESULT_TRACKING_CAPTURE_RETRY_REASONS = setOf(
|
||||
"secure_window",
|
||||
"invalid_window",
|
||||
"interval_short",
|
||||
"accessibility_unavailable",
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var activeInstance: ScreenshotAccessibilityService? = null
|
||||
@@ -1213,6 +1357,17 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
"payment_input_page",
|
||||
)
|
||||
|
||||
internal fun shouldScheduleResultProbe(
|
||||
probeCount: Int,
|
||||
completed: Boolean,
|
||||
expired: Boolean,
|
||||
): Boolean = !completed && !expired && probeCount < MAX_VISUAL_PROBES
|
||||
|
||||
internal fun resultProbeDelayAfterCapture(probeCount: Int): Long =
|
||||
RESULT_PROBE_DELAYS_MS[
|
||||
(probeCount - 1).coerceIn(0, RESULT_PROBE_DELAYS_MS.lastIndex)
|
||||
]
|
||||
|
||||
internal fun completedResultStartReason(
|
||||
hasObservedResult: Boolean,
|
||||
resultFingerprintChanged: Boolean,
|
||||
@@ -1230,6 +1385,14 @@ class ScreenshotAccessibilityService : AccessibilityService() {
|
||||
var isConnected = false
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var lastConnectedAt = 0L
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var lastDisconnectedAt = 0L
|
||||
private set
|
||||
|
||||
fun requestScreenshot(
|
||||
showResult: Boolean,
|
||||
delayMs: Long = 0L,
|
||||
|
||||
@@ -93,7 +93,7 @@ class PaymentParserTest {
|
||||
windowId = 7,
|
||||
flowSessionId = "flow-a",
|
||||
trustedFlow = true,
|
||||
expectedAmountCents = 2_000L,
|
||||
expectedAmountEvidence = expectedEvidence(2_000L, "strong", "flow-a"),
|
||||
expectedType = "expense",
|
||||
resultTransitionObserved = true,
|
||||
submittedFlow = true,
|
||||
@@ -107,7 +107,7 @@ class PaymentParserTest {
|
||||
windowId = 7,
|
||||
flowSessionId = "flow-b",
|
||||
trustedFlow = true,
|
||||
expectedAmountCents = 3_000L,
|
||||
expectedAmountEvidence = expectedEvidence(3_000L, "strong", "flow-b"),
|
||||
expectedType = "expense",
|
||||
resultTransitionObserved = true,
|
||||
)
|
||||
@@ -117,10 +117,46 @@ class PaymentParserTest {
|
||||
@Test
|
||||
fun paymentInputAndAmbiguousAmountsAreRejected() {
|
||||
assertTrue(PaymentParser.isPaymentInputPage("请输入支付密码\n确认转账"))
|
||||
assertTrue(
|
||||
PaymentParser.isPaymentInputPage(
|
||||
"转账给:张三\n转账全额\n¥0.0\n添加转账说明\n转账",
|
||||
),
|
||||
)
|
||||
assertTrue(PaymentParser.amountCandidateCents("转账全额\n¥0.0").isEmpty())
|
||||
assertNull(PaymentParser.parseAmountCandidate("¥0.0"))
|
||||
assertEquals(2_000L, PaymentParser.uniqueAmountCents("付款金额 ¥20.00\n¥20.00"))
|
||||
assertNull(PaymentParser.uniqueAmountCents("¥20.00\n优惠 ¥2.00"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultPageTrackingUsesBoundedProgressiveProbes() {
|
||||
assertEquals(2_500L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(1))
|
||||
assertEquals(5_000L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(2))
|
||||
assertEquals(10_000L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(3))
|
||||
assertEquals(20_000L, ScreenshotAccessibilityService.resultProbeDelayAfterCapture(4))
|
||||
assertTrue(
|
||||
ScreenshotAccessibilityService.shouldScheduleResultProbe(
|
||||
probeCount = 4,
|
||||
completed = false,
|
||||
expired = false,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
ScreenshotAccessibilityService.shouldScheduleResultProbe(
|
||||
probeCount = 5,
|
||||
completed = false,
|
||||
expired = false,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
ScreenshotAccessibilityService.shouldScheduleResultProbe(
|
||||
probeCount = 1,
|
||||
completed = false,
|
||||
expired = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun diagnosticPreviewMasksSensitiveValuesAndLimitsLines() {
|
||||
val preview = OcrDiagnosticRedactor.redact(
|
||||
@@ -149,7 +185,7 @@ class PaymentParserTest {
|
||||
windowId = 8,
|
||||
flowSessionId = "payment-flow",
|
||||
trustedFlow = true,
|
||||
expectedAmountCents = 1_880L,
|
||||
expectedAmountEvidence = expectedEvidence(1_880L, "strong", "payment-flow"),
|
||||
expectedType = "expense",
|
||||
resultTransitionObserved = true,
|
||||
submittedFlow = true,
|
||||
@@ -171,7 +207,7 @@ class PaymentParserTest {
|
||||
windowId = 8,
|
||||
flowSessionId = "payment-flow",
|
||||
trustedFlow = true,
|
||||
expectedAmountCents = 1_880L,
|
||||
expectedAmountEvidence = expectedEvidence(1_880L, "strong", "payment-flow"),
|
||||
expectedType = "expense",
|
||||
resultTransitionObserved = true,
|
||||
submittedFlow = false,
|
||||
@@ -190,7 +226,7 @@ class PaymentParserTest {
|
||||
windowId = 9,
|
||||
flowSessionId = "red-packet-flow",
|
||||
trustedFlow = true,
|
||||
expectedAmountCents = 2_000L,
|
||||
expectedAmountEvidence = expectedEvidence(2_000L, "strong", "red-packet-flow"),
|
||||
expectedType = "expense",
|
||||
resultTransitionObserved = true,
|
||||
submittedFlow = true,
|
||||
@@ -381,6 +417,166 @@ class PaymentParserTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expectedAmountEvidenceIgnoresUnlabelledChatHistory() {
|
||||
val evidence = PaymentParser.expectedAmountEvidence(
|
||||
pageText = "聊天记录\n¥0.01\n你发起了一笔转账\n¥0.02\n确认转账",
|
||||
eventText = "确认转账",
|
||||
flowSessionId = "transfer-003",
|
||||
windowId = 7,
|
||||
capturedAtEpochMs = 1_000L,
|
||||
)
|
||||
|
||||
assertNull(evidence)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun labeledCurrentTransferAmountWinsOverChatHistory() {
|
||||
val evidence = PaymentParser.expectedAmountEvidence(
|
||||
pageText = "聊天记录\n¥0.01\n¥0.02\n转账金额\n¥0.03\n确认转账",
|
||||
eventText = "确认转账",
|
||||
flowSessionId = "transfer-003",
|
||||
windowId = 7,
|
||||
capturedAtEpochMs = 1_000L,
|
||||
)
|
||||
|
||||
assertEquals(3L, evidence?.amountCents)
|
||||
assertEquals("strong", evidence?.strength)
|
||||
assertEquals("labeled_page", evidence?.source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun labeledAmountMustBeOnTheSameOrAdjacentNode() {
|
||||
val evidence = PaymentParser.expectedAmountEvidence(
|
||||
pageText = "转账金额\n付款说明\n¥0.01",
|
||||
eventText = "确认转账",
|
||||
flowSessionId = "transfer-003",
|
||||
windowId = 7,
|
||||
capturedAtEpochMs = 1_000L,
|
||||
)
|
||||
|
||||
assertNull(evidence)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultWithoutAmountUsesOnlyStrongCurrentFlowEvidence() {
|
||||
val outcome = PaymentParser.fromAccessibilityOutcome(
|
||||
packageName = PaymentParser.WECHAT,
|
||||
text = "转账成功",
|
||||
eventTime = 2_000L,
|
||||
windowId = 7,
|
||||
flowSessionId = "transfer-003",
|
||||
trustedFlow = true,
|
||||
expectedAmountEvidence = expectedEvidence(3L, "strong"),
|
||||
expectedType = "expense",
|
||||
resultTransitionObserved = true,
|
||||
submittedFlow = true,
|
||||
flowKind = "transfer",
|
||||
)
|
||||
|
||||
assertEquals(3L, outcome.signal?.amountCents)
|
||||
assertEquals("high", outcome.signal?.evidenceConfidence)
|
||||
assertEquals("expected_amount_fallback", outcome.reason)
|
||||
assertNull(outcome.expectedAmountMatched)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun weakFallbackAndConflictingResultRequireConfirmation() {
|
||||
val weak = PaymentParser.fromAccessibilityOutcome(
|
||||
packageName = PaymentParser.WECHAT,
|
||||
text = "转账成功",
|
||||
eventTime = 2_000L,
|
||||
windowId = 7,
|
||||
flowSessionId = "transfer-003",
|
||||
trustedFlow = true,
|
||||
expectedAmountEvidence = expectedEvidence(3L, "weak"),
|
||||
expectedType = "expense",
|
||||
resultTransitionObserved = true,
|
||||
submittedFlow = true,
|
||||
flowKind = "transfer",
|
||||
)
|
||||
val conflict = PaymentParser.fromAccessibilityOutcome(
|
||||
packageName = PaymentParser.WECHAT,
|
||||
text = "转账成功\n¥0.01",
|
||||
eventTime = 2_000L,
|
||||
windowId = 7,
|
||||
flowSessionId = "transfer-003",
|
||||
trustedFlow = true,
|
||||
expectedAmountEvidence = expectedEvidence(3L, "strong"),
|
||||
expectedType = "expense",
|
||||
resultTransitionObserved = true,
|
||||
submittedFlow = true,
|
||||
flowKind = "transfer",
|
||||
)
|
||||
|
||||
assertEquals("confirm", weak.signal?.evidenceConfidence)
|
||||
assertEquals("weak_expected_fallback", weak.reason)
|
||||
assertEquals(1L, conflict.signal?.amountCents)
|
||||
assertEquals("confirm", conflict.signal?.evidenceConfidence)
|
||||
assertEquals(false, conflict.expectedAmountMatched)
|
||||
assertEquals("result_amount_conflict", conflict.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strongerResultReplacesWeakFallbackButStrongConflictStaysFlagged() {
|
||||
val resultSignal = paymentSignal(
|
||||
channel = "local_ocr",
|
||||
sourceEventId = "ocr:wechat:transfer-003",
|
||||
flowSessionId = "transfer-003",
|
||||
).copy(
|
||||
amountCents = 3L,
|
||||
amountSource = "result",
|
||||
amountEvidenceStrength = "strong",
|
||||
)
|
||||
val replacement = RecognitionStore.mergeAmountEvidence(
|
||||
existingAmountCents = 1L,
|
||||
existingSource = "expected",
|
||||
existingStrength = "weak",
|
||||
incoming = resultSignal,
|
||||
)
|
||||
val conflict = RecognitionStore.mergeAmountEvidence(
|
||||
existingAmountCents = 1L,
|
||||
existingSource = "expected",
|
||||
existingStrength = "strong",
|
||||
incoming = resultSignal,
|
||||
)
|
||||
|
||||
assertEquals(3L, replacement.amountCents)
|
||||
assertFalse(replacement.conflict)
|
||||
assertEquals(1L, conflict.amountCents)
|
||||
assertTrue(conflict.conflict)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun consecutiveSmallTransfersKeepFourFlowsAndSumToSevenCents() {
|
||||
val amounts = listOf(1L, 1L, 2L, 3L)
|
||||
val signals = amounts.mapIndexed { index, cents ->
|
||||
paymentSignal(
|
||||
channel = "accessibility",
|
||||
sourceEventId = "a:wechat:transfer-$index",
|
||||
flowSessionId = "transfer-$index",
|
||||
).copy(amountCents = cents)
|
||||
}
|
||||
|
||||
assertEquals(7L, signals.sumOf { it.amountCents })
|
||||
assertEquals(4, signals.map(RecognitionStore::flowStrongKeyFor).toSet().size)
|
||||
}
|
||||
|
||||
private fun expectedEvidence(
|
||||
amountCents: Long,
|
||||
strength: String,
|
||||
flowSessionId: String = "transfer-003",
|
||||
) = ExpectedAmountEvidence(
|
||||
amountCents = amountCents,
|
||||
flowSessionId = flowSessionId,
|
||||
windowId = 7,
|
||||
pageFingerprint = "payment-page",
|
||||
capturedAtEpochMs = 1_500L,
|
||||
candidateCount = 1,
|
||||
source = "labeled_page",
|
||||
strength = strength,
|
||||
)
|
||||
|
||||
private fun paymentSignal(
|
||||
channel: String,
|
||||
sourceEventId: String,
|
||||
|
||||
@@ -49,7 +49,6 @@ final router = GoRouter(
|
||||
refreshListenable: SessionStore.instance,
|
||||
redirect: (_, state) {
|
||||
final aiOnly =
|
||||
state.matchedLocation == '/chat' ||
|
||||
state.matchedLocation == '/ai-mode' ||
|
||||
state.matchedLocation == '/companion';
|
||||
if (aiOnly &&
|
||||
@@ -86,7 +85,15 @@ final router = GoRouter(
|
||||
path: '/categories',
|
||||
builder: (_, __) => const CategoryManagePage(),
|
||||
),
|
||||
GoRoute(path: '/report', builder: (_, __) => const ReportPage()),
|
||||
GoRoute(
|
||||
path: '/report',
|
||||
builder: (_, state) => ReportPage(
|
||||
initialPeriod: state.uri.queryParameters['period'],
|
||||
initialAnchor: DateTime.tryParse(
|
||||
state.uri.queryParameters['date'] ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/legal/:kind',
|
||||
builder: (_, state) => LegalDocumentPage(
|
||||
@@ -175,20 +182,33 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
|
||||
if (SessionStore.instance.hasSession) {
|
||||
await _runSafely(CurrentLedgerStore.instance.loadCached);
|
||||
}
|
||||
await _runSafely(RecognitionImportService.configureNativeContext);
|
||||
await _runSafely(RecognitionImportService.importAutomatic);
|
||||
await _runSafely(_restoreRecognitionServices);
|
||||
await _runSafely(PushService.instance.initialize);
|
||||
if (mounted) setState(() {});
|
||||
unawaited(_refreshRemoteState());
|
||||
}
|
||||
|
||||
Future<void> _resumeServices() async {
|
||||
await _runSafely(RecognitionImportService.configureNativeContext);
|
||||
await _runSafely(RecognitionImportService.importAutomatic);
|
||||
unawaited(ApiClient.instance.probe());
|
||||
await _runSafely(_restoreRecognitionServices);
|
||||
await _runSafely(PushService.instance.refresh);
|
||||
unawaited(_refreshRemoteState());
|
||||
}
|
||||
|
||||
Future<void> _restoreRecognitionServices() async {
|
||||
await RecognitionImportService.configureNativeContext();
|
||||
await ScreenshotChannel.ensureRecognitionKeepAlive();
|
||||
await RecognitionImportService.importAutomatic();
|
||||
unawaited(_runSafely(_importAfterAccessibilityReconnect));
|
||||
}
|
||||
|
||||
Future<void> _importAfterAccessibilityReconnect() async {
|
||||
final status = await ScreenshotChannel.waitForAccessibilityConnection();
|
||||
if (status.accessibilityConnected) {
|
||||
await RecognitionImportService.importAutomatic();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshRemoteState() async {
|
||||
await Future.wait([
|
||||
_runSafely(PublicConfigApi.init),
|
||||
@@ -219,6 +239,10 @@ class _MiaoJiAppState extends State<MiaoJiApp> with WidgetsBindingObserver {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 180));
|
||||
final context = _rootNavigatorKey.currentContext;
|
||||
if (!mounted || context == null || !context.mounted) return;
|
||||
if (action['action'] == 'open_recognition_settings') {
|
||||
router.push('/screenshot-settings');
|
||||
return;
|
||||
}
|
||||
await RecognitionImportService.handleAction(context, action);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
@@ -7,6 +9,7 @@ import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||
|
||||
class AddPage extends StatefulWidget {
|
||||
@@ -36,6 +39,7 @@ class _AddPageState extends State<AddPage> {
|
||||
DateTime _occurredAt = ShanghaiTime.now;
|
||||
bool _saving = false;
|
||||
bool _loadingCategories = true;
|
||||
int _loadRevision = 0;
|
||||
String get _categoryType => _tab == 'transfer'
|
||||
? _transferDirection == 'in'
|
||||
? 'income'
|
||||
@@ -60,7 +64,7 @@ class _AddPageState extends State<AddPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadCategories();
|
||||
unawaited(_loadCategories());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -71,38 +75,60 @@ class _AddPageState extends State<AddPage> {
|
||||
}
|
||||
|
||||
Future<void> _loadCategories() async {
|
||||
setState(() => _loadingCategories = true);
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
TxApi.categories('expense'),
|
||||
TxApi.categories('income'),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
final revision = ++_loadRevision;
|
||||
final expense = TxApi.categoriesLocal('expense');
|
||||
final income = TxApi.categoriesLocal('income');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_categoriesByType['expense'] = results[0];
|
||||
_categoriesByType['income'] = results[1];
|
||||
if (_selectedByType['expense'] == null && results[0].isNotEmpty) {
|
||||
_selectedByType['expense'] = results[0].first;
|
||||
}
|
||||
if (_selectedByType['income'] == null && results[1].isNotEmpty) {
|
||||
_selectedByType['income'] = results[1].first;
|
||||
}
|
||||
_selectedByType['transfer_out'] ??= results[0].isEmpty
|
||||
? null
|
||||
: results[0].first;
|
||||
_selectedByType['transfer_in'] ??= results[1].isEmpty
|
||||
? null
|
||||
: results[1].first;
|
||||
_applyCategories(expense, income);
|
||||
_loadingCategories = false;
|
||||
});
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loadingCategories = false);
|
||||
}
|
||||
await Future.wait([
|
||||
TxApi.categoriesRemote('expense').then<void>((values) {
|
||||
if (!mounted || revision != _loadRevision) return;
|
||||
setState(() {
|
||||
_applyCategories(values, _categoriesByType['income'] ?? const []);
|
||||
});
|
||||
}, onError: (_) {}),
|
||||
TxApi.categoriesRemote('income').then<void>((values) {
|
||||
if (!mounted || revision != _loadRevision) return;
|
||||
setState(() {
|
||||
_applyCategories(_categoriesByType['expense'] ?? const [], values);
|
||||
});
|
||||
}, onError: (_) {}),
|
||||
]);
|
||||
}
|
||||
|
||||
void _applyCategories(List<CategoryItem> expense, List<CategoryItem> income) {
|
||||
_categoriesByType['expense'] = expense;
|
||||
_categoriesByType['income'] = income;
|
||||
_selectedByType['expense'] = _preserveSelection(
|
||||
_selectedByType['expense'],
|
||||
expense,
|
||||
);
|
||||
_selectedByType['income'] = _preserveSelection(
|
||||
_selectedByType['income'],
|
||||
income,
|
||||
);
|
||||
_selectedByType['transfer_out'] = _preserveSelection(
|
||||
_selectedByType['transfer_out'],
|
||||
expense,
|
||||
);
|
||||
_selectedByType['transfer_in'] = _preserveSelection(
|
||||
_selectedByType['transfer_in'],
|
||||
income,
|
||||
);
|
||||
}
|
||||
|
||||
CategoryItem? _preserveSelection(
|
||||
CategoryItem? selected,
|
||||
List<CategoryItem> values,
|
||||
) {
|
||||
if (values.isEmpty) return null;
|
||||
if (selected == null) return values.first;
|
||||
return values.where((item) => item.id == selected.id).firstOrNull ??
|
||||
values.first;
|
||||
}
|
||||
|
||||
void _switchTab(String tab) {
|
||||
@@ -162,6 +188,7 @@ class _AddPageState extends State<AddPage> {
|
||||
note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(),
|
||||
paymentMethod: _paymentMethod,
|
||||
occurredAt: _occurredAt,
|
||||
localFirst: true,
|
||||
);
|
||||
TransactionEvents.notifyChanged();
|
||||
if (mounted) context.pop(amount);
|
||||
@@ -239,6 +266,7 @@ class _AddPageState extends State<AddPage> {
|
||||
),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
actions: [BackendStatusIcon(onRetry: _loadCategories)],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
|
||||
@@ -743,7 +743,8 @@ class _ChatPageState extends State<ChatPage> {
|
||||
}
|
||||
|
||||
Widget _inputBar(BuildContext context) {
|
||||
final sendEnabled = _ctrl.text.trim().isNotEmpty && !_sending;
|
||||
final hasText = _ctrl.text.trim().isNotEmpty;
|
||||
final sendEnabled = hasText && !_sending;
|
||||
final accent = widget.aiMode ? AppTheme.ai : AppTheme.primary;
|
||||
return Container(
|
||||
color: context.jz.card,
|
||||
@@ -866,23 +867,59 @@ class _ChatPageState extends State<ChatPage> {
|
||||
),
|
||||
SizedBox(width: 6),
|
||||
],
|
||||
SizedBox(
|
||||
width: 50,
|
||||
height: 38,
|
||||
child: FilledButton(
|
||||
onPressed: sendEnabled ? _send : null,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor: accent,
|
||||
disabledBackgroundColor: context.jz.line,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
transitionBuilder: (child, animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: ScaleTransition(
|
||||
scale: Tween<double>(begin: 0.9, end: 1).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'发送',
|
||||
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w700),
|
||||
),
|
||||
child: hasText
|
||||
? SizedBox(
|
||||
key: const ValueKey('send-message'),
|
||||
width: 50,
|
||||
height: 38,
|
||||
child: FilledButton(
|
||||
onPressed: sendEnabled ? _send : null,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor: accent,
|
||||
disabledBackgroundColor: context.jz.line,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'发送',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: PublicConfigApi.imageEnabled
|
||||
? SizedBox(
|
||||
key: const ValueKey('add-attachment'),
|
||||
width: 42,
|
||||
height: 42,
|
||||
child: IconButton(
|
||||
tooltip: '添加附件',
|
||||
onPressed: _sending ? null : _openAttachment,
|
||||
icon: AppIcons.icon(
|
||||
AppIcons.plus,
|
||||
size: 22,
|
||||
color: _sending ? context.jz.text3 : context.jz.text2,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('no-composer-action')),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -948,24 +985,6 @@ class _ChatPageState extends State<ChatPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (PublicConfigApi.imageEnabled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ActionChip(
|
||||
avatar: AppIcons.icon(
|
||||
AppIcons.camera,
|
||||
size: 16,
|
||||
color: AppTheme.ai,
|
||||
),
|
||||
label: Text(
|
||||
'附件',
|
||||
style: TextStyle(fontSize: 11, color: context.jz.text2),
|
||||
),
|
||||
backgroundColor: context.jz.background,
|
||||
side: BorderSide.none,
|
||||
onPressed: _openAttachment,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1280,6 +1299,7 @@ class _ChatTabPageState extends State<ChatTabPage> {
|
||||
final status = switch (accessState) {
|
||||
AiAccessState.guest => '登录后可用',
|
||||
AiAccessState.reauthenticate => '需要重新登录',
|
||||
AiAccessState.aiDisabled => 'AI 功能已关闭',
|
||||
AiAccessState.cloudDisabled => '云连接已关闭',
|
||||
null => '在线',
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
@@ -10,6 +12,7 @@ import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||
import 'package:miaoji_zhang/features/home/pages/tx_detail_page.dart';
|
||||
import 'package:miaoji_zhang/features/home/pages/ledger_sheet.dart';
|
||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||
@@ -28,6 +31,7 @@ class HomePageState extends State<HomePage> {
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
late DateTime _month;
|
||||
int _loadRevision = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -36,7 +40,7 @@ class HomePageState extends State<HomePage> {
|
||||
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
|
||||
TransactionEvents.revision.addListener(_refreshTransactions);
|
||||
CurrentLedgerStore.instance.addListener(_refreshLedger);
|
||||
refresh();
|
||||
unawaited(refresh());
|
||||
}
|
||||
|
||||
void _refreshCompanion() {
|
||||
@@ -60,33 +64,56 @@ class HomePageState extends State<HomePage> {
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final revision = ++_loadRevision;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await CurrentLedgerStore.instance.ensureLoaded();
|
||||
final results = await Future.wait<dynamic>([
|
||||
TxApi.month(_month.year, _month.month),
|
||||
BudgetApi.get(_month.year, _month.month),
|
||||
]);
|
||||
if (mounted) {
|
||||
await CurrentLedgerStore.instance.loadCached();
|
||||
final localSummary = TxApi.monthLocal(_month.year, _month.month);
|
||||
final localBudgets = BudgetApi.getLocal(_month.year, _month.month);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() {
|
||||
_summary = results[0] as MonthSummary;
|
||||
_budgets = results[1] as BudgetsData;
|
||||
_summary = localSummary;
|
||||
_budgets = localBudgets;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
final summary = await TxApi.month(_month.year, _month.month);
|
||||
if (mounted) setState(() => _summary = summary);
|
||||
} catch (fallbackError) {
|
||||
if (mounted) {
|
||||
setState(() => _error = apiErrorMessage(fallbackError));
|
||||
}
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() {
|
||||
_error = apiErrorMessage(error);
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
return;
|
||||
}
|
||||
|
||||
await Future.wait([
|
||||
() async {
|
||||
try {
|
||||
await CurrentLedgerStore.instance.refreshRemote();
|
||||
} catch (_) {}
|
||||
}(),
|
||||
() async {
|
||||
try {
|
||||
final summary = await TxApi.monthRemote(_month.year, _month.month);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _summary = summary);
|
||||
}
|
||||
} catch (_) {}
|
||||
}(),
|
||||
() async {
|
||||
try {
|
||||
final budgets = await BudgetApi.getRemote(_month.year, _month.month);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _budgets = budgets);
|
||||
}
|
||||
} catch (_) {}
|
||||
}(),
|
||||
]);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +202,7 @@ class HomePageState extends State<HomePage> {
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
BackendStatusIcon(onRetry: refresh),
|
||||
IconButton(
|
||||
icon: AppIcons.icon(
|
||||
AppIcons.search,
|
||||
|
||||
@@ -25,14 +25,6 @@ class _MainShellState extends State<MainShell> {
|
||||
return AnimatedBuilder(
|
||||
animation: SessionStore.instance,
|
||||
builder: (context, _) {
|
||||
final session = SessionStore.instance;
|
||||
final aiEnabled = session.aiEnabled;
|
||||
final showAiEntry = session.isGuest || aiEnabled;
|
||||
if (!showAiEntry && widget.shell.currentIndex == 2) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.shell.goBranch(0);
|
||||
});
|
||||
}
|
||||
return Scaffold(
|
||||
body: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
@@ -58,12 +50,11 @@ class _MainShellState extends State<MainShell> {
|
||||
_tab('明细', AppIcons.home, 0),
|
||||
_tab('统计', AppIcons.chart, 1),
|
||||
SizedBox(width: 72, child: _fab()),
|
||||
if (showAiEntry)
|
||||
ValueListenableBuilder<CompanionDisplay>(
|
||||
valueListenable: PublicConfigApi.companionNotifier,
|
||||
builder: (_, companion, __) =>
|
||||
_tab(companion.name, AppIcons.chat, 2),
|
||||
),
|
||||
ValueListenableBuilder<CompanionDisplay>(
|
||||
valueListenable: PublicConfigApi.companionNotifier,
|
||||
builder: (_, companion, __) =>
|
||||
_tab(companion.name, AppIcons.chat, 2),
|
||||
),
|
||||
_tab('我的', AppIcons.user, 3),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -31,37 +31,42 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
}
|
||||
|
||||
Future<void> _loadConfig() async {
|
||||
if (mounted) setState(() => _loaded = false);
|
||||
final results = await Future.wait([_loadAvatars(), _loadPersonas()]);
|
||||
if (!mounted) return;
|
||||
final avatars = results[0] as List<AvatarItem>;
|
||||
final personas = results[1] as List<PersonaItem>;
|
||||
setState(() {
|
||||
_avatars = avatars;
|
||||
_personas = personas;
|
||||
if (!avatars.any((item) => item.key == _avatar) && avatars.isNotEmpty) {
|
||||
_avatar = avatars.first.key;
|
||||
}
|
||||
if (!personas.any((item) => item.key == _persona) && personas.isNotEmpty) {
|
||||
_persona = personas.first.key;
|
||||
}
|
||||
_loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<AvatarItem>> _loadAvatars() async {
|
||||
try {
|
||||
final avatars = await PublicConfigApi.avatars();
|
||||
final personas = await PublicConfigApi.personas();
|
||||
if (mounted)
|
||||
setState(() {
|
||||
_avatars = avatars;
|
||||
_personas = personas;
|
||||
if (avatars.isNotEmpty) _avatar = avatars.first.key;
|
||||
if (personas.isNotEmpty) _persona = personas.first.key;
|
||||
_loaded = true;
|
||||
});
|
||||
return await PublicConfigApi.avatars();
|
||||
} catch (_) {
|
||||
// API 失败用硬编码兜底
|
||||
if (mounted) setState(() => _loaded = true);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底数据(API 不可用时)
|
||||
static const _fallbackAvatars = [
|
||||
('cat', '小账喵', AppIcons.cat),
|
||||
('dog', '阿福汪', AppIcons.dog),
|
||||
('robot', '账小智', AppIcons.robot),
|
||||
];
|
||||
static const _fallbackPersonas = [
|
||||
('sassy_cat', '毒舌猫娘', '乱花钱会被无情吐槽'),
|
||||
('gentle', '温柔小暖', '永远鼓励,温柔提醒'),
|
||||
('strict', '严格管家', '理性专业,数据说话'),
|
||||
('meme', '沙雕损友', '玩梗高手,快乐记账'),
|
||||
];
|
||||
Future<List<PersonaItem>> _loadPersonas() async {
|
||||
try {
|
||||
return await PublicConfigApi.personas();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _finish() async {
|
||||
if (!_hasValidCatalogSelection) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await AuthApi.completeOnboarding(
|
||||
@@ -81,6 +86,10 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
}
|
||||
}
|
||||
|
||||
bool get _hasValidCatalogSelection =>
|
||||
_avatars.any((item) => item.key == _avatar) &&
|
||||
_personas.any((item) => item.key == _persona);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_loaded)
|
||||
@@ -170,9 +179,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
}
|
||||
|
||||
Widget _CompanionStep() {
|
||||
// 优先 API 数据,回退硬编码
|
||||
final avatars = _avatars.isNotEmpty
|
||||
? _avatars
|
||||
final avatars = _avatars
|
||||
.map(
|
||||
(a) => (
|
||||
a.key,
|
||||
@@ -184,11 +191,10 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
: AppIcons.cat,
|
||||
),
|
||||
)
|
||||
.toList()
|
||||
: _fallbackAvatars;
|
||||
final personas = _personas.isNotEmpty
|
||||
? _personas.map((p) => (p.key, p.name, p.description)).toList()
|
||||
: _fallbackPersonas;
|
||||
.toList();
|
||||
final personas = _personas
|
||||
.map((p) => (p.key, p.name, p.description))
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -226,6 +232,35 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
style: TextStyle(fontSize: 12, color: context.jz.text2),
|
||||
),
|
||||
SizedBox(height: 14),
|
||||
if (avatars.isEmpty || personas.isEmpty)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppIcons.icon(AppIcons.cloud, size: 28, color: context.jz.text3),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'AI 伙伴配置暂不可用',
|
||||
style: TextStyle(
|
||||
color: context.jz.text,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('请检查网络后重试', style: TextStyle(color: context.jz.text2, fontSize: 12)),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
onPressed: _loadConfig,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('重新加载'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
SizedBox(
|
||||
height: 96,
|
||||
child: Row(
|
||||
@@ -304,7 +339,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
curve: Curves.easeOut,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: on ? context.jz.aiBackground : Colors.white,
|
||||
color: on ? context.jz.aiBackground : context.jz.card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: on ? AppTheme.ai : context.jz.line,
|
||||
@@ -339,11 +374,12 @@ class _OnboardingPageState extends State<OnboardingPage> {
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
|
||||
onPressed: _saving ? null : _finish,
|
||||
onPressed: _saving || !_hasValidCatalogSelection ? null : _finish,
|
||||
child: _saving
|
||||
? SizedBox(
|
||||
height: 20,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:miaoji_zhang/shared/api/business_api.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
||||
|
||||
class CategoryManagePage extends StatefulWidget {
|
||||
@@ -20,6 +21,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
||||
bool _reordering = false;
|
||||
bool _savingOrder = false;
|
||||
bool _orderDirty = false;
|
||||
int _loadRevision = 0;
|
||||
|
||||
List<CategoryItem> get _custom =>
|
||||
_cats.where((category) => category.isCustom).toList();
|
||||
@@ -31,19 +33,31 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final revision = ++_loadRevision;
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final cats = await TxApi.categories(_type);
|
||||
if (mounted) {
|
||||
final type = _type;
|
||||
final cached = TxApi.categoriesLocal(type);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() {
|
||||
_cats = cats;
|
||||
_cats = cached;
|
||||
_orderDirty = false;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
final remote = await TxApi.categoriesRemote(type);
|
||||
if (mounted && revision == _loadRevision && type == _type) {
|
||||
setState(() => _cats = remote);
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) _showError(error);
|
||||
if (mounted && revision == _loadRevision && _loading) {
|
||||
setState(() => _loading = false);
|
||||
_showError(error);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
if (mounted && revision == _loadRevision && _loading) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +65,13 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
||||
final result = await _showEditor();
|
||||
if (result == null) return;
|
||||
try {
|
||||
await CategoryApi.create(result.name, result.icon, result.color, _type);
|
||||
await CategoryApi.create(
|
||||
result.name,
|
||||
result.icon,
|
||||
result.color,
|
||||
_type,
|
||||
localFirst: true,
|
||||
);
|
||||
await _load();
|
||||
} catch (error) {
|
||||
if (mounted) _showError(error);
|
||||
@@ -68,6 +88,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
||||
iconKey: result.icon,
|
||||
colorKey: result.color,
|
||||
sortOrder: category.sortOrder,
|
||||
localFirst: true,
|
||||
);
|
||||
await _load();
|
||||
} catch (error) {
|
||||
@@ -361,7 +382,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
||||
);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await CategoryApi.delete(category.id);
|
||||
await CategoryApi.delete(category.id, localFirst: true);
|
||||
await _load();
|
||||
} catch (error) {
|
||||
if (mounted) _showError(error);
|
||||
@@ -396,7 +417,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
||||
final ids = _custom.map((category) => category.id).toList();
|
||||
setState(() => _savingOrder = true);
|
||||
try {
|
||||
await CategoryApi.reorder(_type, ids);
|
||||
await CategoryApi.reorder(_type, ids, localFirst: true);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_reordering = false;
|
||||
@@ -424,6 +445,7 @@ class _CategoryManagePageState extends State<CategoryManagePage> {
|
||||
appBar: AppBar(
|
||||
title: Text('分类管理'),
|
||||
actions: [
|
||||
BackendStatusIcon(onRetry: _load),
|
||||
if (_custom.length > 1)
|
||||
TextButton(
|
||||
onPressed: _savingOrder ? null : _toggleReorder,
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:miaoji_zhang/shared/api/config_api.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||
|
||||
/// AI 性格设置页(P5):形象/性格从后台 API 动态拉取
|
||||
class CompanionPage extends StatefulWidget {
|
||||
@@ -16,7 +17,9 @@ class CompanionPage extends StatefulWidget {
|
||||
class _CompanionPageState extends State<CompanionPage> {
|
||||
String _avatar = 'cat', _persona = 'sassy_cat';
|
||||
double _roast = 60, _sticker = 70, _proactive = 40;
|
||||
bool _saving = false, _loaded = false;
|
||||
bool _saving = false;
|
||||
bool _refreshing = false;
|
||||
int _loadRevision = 0;
|
||||
List<AvatarItem> _avatars = [];
|
||||
List<PersonaItem> _personas = [];
|
||||
|
||||
@@ -40,27 +43,52 @@ class _CompanionPageState extends State<CompanionPage> {
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final p = await AuthApi.me();
|
||||
if (mounted && p.aiCompanion != null)
|
||||
setState(() {
|
||||
_avatar = p.aiCompanion!.avatarKey;
|
||||
_persona = p.aiCompanion!.personaKey;
|
||||
_roast = p.aiCompanion!.roastLevel.toDouble();
|
||||
_sticker = p.aiCompanion!.stickerFrequency.toDouble();
|
||||
_proactive = p.aiCompanion!.proactiveLevel.toDouble();
|
||||
});
|
||||
} catch (_) {}
|
||||
try {
|
||||
final av = await PublicConfigApi.avatars();
|
||||
final ps = await PublicConfigApi.personas();
|
||||
if (mounted)
|
||||
setState(() {
|
||||
_avatars = av;
|
||||
_personas = ps;
|
||||
});
|
||||
} catch (_) {}
|
||||
if (mounted) setState(() => _loaded = true);
|
||||
final revision = ++_loadRevision;
|
||||
setState(() => _refreshing = true);
|
||||
|
||||
final cached = await Future.wait<Object?>([
|
||||
AuthApi.cachedCompanion(),
|
||||
PublicConfigApi.cachedAvatars(),
|
||||
PublicConfigApi.cachedPersonas(),
|
||||
]);
|
||||
if (!mounted || revision != _loadRevision) return;
|
||||
_applyCompanion(cached[0] as AiCompanion?);
|
||||
setState(() {
|
||||
_avatars = cached[1] as List<AvatarItem>;
|
||||
_personas = cached[2] as List<PersonaItem>;
|
||||
});
|
||||
|
||||
await Future.wait([
|
||||
AuthApi.me(forceRemote: true).then<void>((profile) {
|
||||
if (mounted && revision == _loadRevision) {
|
||||
_applyCompanion(profile.aiCompanion);
|
||||
}
|
||||
}, onError: (_) {}),
|
||||
PublicConfigApi.avatars().then<void>((values) {
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _avatars = values);
|
||||
}
|
||||
}, onError: (_) {}),
|
||||
PublicConfigApi.personas().then<void>((values) {
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _personas = values);
|
||||
}
|
||||
}, onError: (_) {}),
|
||||
]);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _refreshing = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyCompanion(AiCompanion? companion) {
|
||||
if (companion == null || !mounted) return;
|
||||
setState(() {
|
||||
_avatar = companion.avatarKey;
|
||||
_persona = companion.personaKey;
|
||||
_roast = companion.roastLevel.toDouble();
|
||||
_sticker = companion.stickerFrequency.toDouble();
|
||||
_proactive = companion.proactiveLevel.toDouble();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
@@ -80,26 +108,20 @@ class _CompanionPageState extends State<CompanionPage> {
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('已保存')));
|
||||
} catch (e) {
|
||||
if (mounted)
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
if (mounted) {
|
||||
setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_loaded)
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppTheme.primary,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
);
|
||||
final avatars = _avatars.isNotEmpty
|
||||
? _avatars
|
||||
.map(
|
||||
@@ -122,7 +144,16 @@ class _CompanionPageState extends State<CompanionPage> {
|
||||
: _fallbackPersonas;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('AI 性格设置')),
|
||||
appBar: AppBar(
|
||||
title: Text('AI 性格设置'),
|
||||
actions: [BackendStatusIcon(onRetry: _load)],
|
||||
bottom: _refreshing
|
||||
? const PreferredSize(
|
||||
preferredSize: Size.fromHeight(2),
|
||||
child: LinearProgressIndicator(minHeight: 2),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
@@ -236,19 +267,24 @@ class _CompanionPageState extends State<CompanionPage> {
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
|
||||
onPressed: _saving ? null : _save,
|
||||
child: _saving
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text('保存设置'),
|
||||
ValueListenableBuilder<BackendAvailability>(
|
||||
valueListenable: ApiClient.availability,
|
||||
builder: (context, availability, _) => ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
|
||||
onPressed: availability == BackendAvailability.online && !_saving
|
||||
? _save
|
||||
: null,
|
||||
child: _saving
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text('保存设置'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -264,13 +264,6 @@ class _MePageState extends State<MePage> {
|
||||
'预算管理',
|
||||
onTap: () => context.push('/budget'),
|
||||
),
|
||||
_row(
|
||||
AppIcons.avatarAsset(PublicConfigApi.companionAvatarKey),
|
||||
context.jz.aiBackground,
|
||||
AppTheme.ai,
|
||||
session.aiEnabled ? 'AI 报告' : '报告',
|
||||
onTap: () => context.push('/report'),
|
||||
),
|
||||
_row(
|
||||
AppIcons.tag,
|
||||
context.jz.primaryBackground,
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:miaoji_zhang/shared/services/transaction_events.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||
|
||||
class RecycleBinPage extends StatefulWidget {
|
||||
const RecycleBinPage({super.key});
|
||||
@@ -17,6 +18,7 @@ class RecycleBinPage extends StatefulWidget {
|
||||
class _RecycleBinPageState extends State<RecycleBinPage> {
|
||||
List<TxItem> _items = const [];
|
||||
bool _loading = true;
|
||||
int _loadRevision = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -25,15 +27,30 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final revision = ++_loadRevision;
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await CurrentLedgerStore.instance.ensureLoaded();
|
||||
final items = await TxApi.recycleBin();
|
||||
if (mounted) setState(() => _items = items);
|
||||
await CurrentLedgerStore.instance.loadCached();
|
||||
final cached = TxApi.recycleBinLocal();
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() {
|
||||
_items = cached;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
final remote = await TxApi.recycleBinRemote();
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _items = remote);
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) _showError(error);
|
||||
if (mounted && revision == _loadRevision && _loading) {
|
||||
setState(() => _loading = false);
|
||||
_showError(error);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
if (mounted && revision == _loadRevision && _loading) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +65,7 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
||||
}
|
||||
|
||||
Future<void> _permanentDelete(TxItem item) async {
|
||||
if (ApiClient.availability.value != BackendAvailability.online) return;
|
||||
final confirmed = await _confirm('永久删除', '永久删除后无法恢复,聊天中的账单卡片会显示为已删除。');
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
@@ -59,7 +77,10 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
||||
}
|
||||
|
||||
Future<void> _clear() async {
|
||||
if (_items.isEmpty) return;
|
||||
if (_items.isEmpty ||
|
||||
ApiClient.availability.value != BackendAvailability.online) {
|
||||
return;
|
||||
}
|
||||
final confirmed = await _confirm('清空回收站', '将永久删除当前账本回收站中的全部账单,无法恢复。');
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
@@ -79,12 +100,14 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
||||
);
|
||||
|
||||
Future<void> _showActions(TxItem item) async {
|
||||
final online = ApiClient.availability.value == BackendAvailability.online;
|
||||
final action = await showJzOptionSheet<String>(
|
||||
context,
|
||||
title: item.note ?? item.categoryName,
|
||||
options: const [
|
||||
JzOption(value: 'restore', label: '恢复账单'),
|
||||
JzOption(value: 'delete', label: '永久删除', subtitle: '删除后无法恢复'),
|
||||
options: [
|
||||
const JzOption(value: 'restore', label: '恢复账单'),
|
||||
if (online)
|
||||
const JzOption(value: 'delete', label: '永久删除', subtitle: '删除后无法恢复'),
|
||||
],
|
||||
);
|
||||
if (action == 'restore') await _restore(item);
|
||||
@@ -103,9 +126,16 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
||||
appBar: AppBar(
|
||||
title: Text('${CurrentLedgerStore.instance.currentName} · 回收站'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _items.isEmpty ? null : _clear,
|
||||
child: Text('清空'),
|
||||
BackendStatusIcon(onRetry: _load),
|
||||
ValueListenableBuilder<BackendAvailability>(
|
||||
valueListenable: ApiClient.availability,
|
||||
builder: (context, availability, _) => TextButton(
|
||||
onPressed:
|
||||
_items.isEmpty || availability != BackendAvailability.online
|
||||
? null
|
||||
: _clear,
|
||||
child: Text('清空'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -27,6 +27,9 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
||||
Timer? _diagnosticRefreshTimer;
|
||||
Timer? _previewExpiryTimer;
|
||||
bool _diagnosticRefreshInFlight = false;
|
||||
bool _connectionCheckInFlight = false;
|
||||
bool _keepAliveStarting = false;
|
||||
bool _recentsProtectionNoticeChecked = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -60,14 +63,14 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
||||
void _refreshRunningDiagnostic() {
|
||||
if (!mounted ||
|
||||
_diagnosticRefreshInFlight ||
|
||||
_status?.latestDiagnostic?.result != 'started') {
|
||||
!{'started', 'waiting'}.contains(_status?.latestDiagnostic?.result)) {
|
||||
return;
|
||||
}
|
||||
_diagnosticRefreshInFlight = true;
|
||||
_check().whenComplete(() => _diagnosticRefreshInFlight = false);
|
||||
}
|
||||
|
||||
Future<void> _check() async {
|
||||
Future<void> _check({bool monitorConnection = true}) async {
|
||||
try {
|
||||
var status = await ScreenshotChannel.recognitionStatus();
|
||||
final invalid = <String>[
|
||||
@@ -97,6 +100,14 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
||||
_error = null;
|
||||
});
|
||||
_schedulePreviewExpiry(status);
|
||||
unawaited(_showRecentsProtectionNotice(status));
|
||||
if (monitorConnection &&
|
||||
status.accessibilityAuthorized &&
|
||||
!status.accessibilityConnected &&
|
||||
!status.taskCleanerRecoveryNeeded &&
|
||||
(status.accessibilityEvents || status.aiScreenshot)) {
|
||||
unawaited(_monitorAccessibilityConnection());
|
||||
}
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -106,6 +117,49 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showRecentsProtectionNotice(RecognitionStatus status) async {
|
||||
if (_recentsProtectionNoticeChecked || !status.recentsProtectionActive) {
|
||||
return;
|
||||
}
|
||||
_recentsProtectionNoticeChecked = true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
const key = 'originos_recents_protection_notice_v1';
|
||||
if (prefs.getBool(key) == true || !mounted) return;
|
||||
await prefs.setBool(key, true);
|
||||
if (!mounted) return;
|
||||
_showMessage('记之已从最近任务隐藏,可从桌面图标重新打开;关闭全部识别后恢复。');
|
||||
}
|
||||
|
||||
Future<void> _monitorAccessibilityConnection({
|
||||
bool ensureKeepAlive = false,
|
||||
}) async {
|
||||
if (_connectionCheckInFlight) return;
|
||||
_connectionCheckInFlight = true;
|
||||
if (mounted) setState(() {});
|
||||
try {
|
||||
if (ensureKeepAlive) {
|
||||
_keepAliveStarting = true;
|
||||
if (mounted) setState(() {});
|
||||
await ScreenshotChannel.ensureRecognitionKeepAlive();
|
||||
}
|
||||
await ScreenshotChannel.waitForAccessibilityConnection();
|
||||
final status = await ScreenshotChannel.recognitionStatus();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_status = status;
|
||||
_error = null;
|
||||
});
|
||||
_schedulePreviewExpiry(status);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = '连接状态复核失败,请重试');
|
||||
} finally {
|
||||
_connectionCheckInFlight = false;
|
||||
_keepAliveStarting = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _schedulePreviewExpiry(RecognitionStatus status) {
|
||||
_previewExpiryTimer?.cancel();
|
||||
final expiresAt = status.ocrDiagnosticPreviewExpiresAt;
|
||||
@@ -431,7 +485,32 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
||||
'仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 OCR;不保存图片、完整控件树,也不监听按键。',
|
||||
authorized: status!.accessibilityAuthorized,
|
||||
connected: status.accessibilityConnected,
|
||||
statusLabel:
|
||||
_connectionCheckInFlight && !status.accessibilityConnected
|
||||
? '正在连接'
|
||||
: status.accessibilityConnectionLabel,
|
||||
recoveryMessage: status.accessibilityNeedsRecovery
|
||||
? status.taskCleanerRecoveryNeeded
|
||||
? 'OriginOS 清理了识别进程,系统仍保留授权,但无障碍服务已被标记故障。'
|
||||
: '系统仍显示已授权,但 OriginOS 未重新绑定服务。请前往系统设置,将记之无障碍服务关闭后重新开启。'
|
||||
: (status.accessibilityEvents || status.aiScreenshot) &&
|
||||
status.keepAliveNeedsRecovery
|
||||
? _keepAliveStarting
|
||||
? '正在启动后台保护…'
|
||||
: status.keepAliveError ?? '后台保护未运行,划掉应用后识别可能中断。'
|
||||
: null,
|
||||
onOpenSettings: ScreenshotChannel.openAccessibilitySettings,
|
||||
settingsLabel: '前往无障碍设置',
|
||||
onRetry: status.accessibilityNeedsRecovery
|
||||
? () => _monitorAccessibilityConnection()
|
||||
: null,
|
||||
onStartKeepAlive:
|
||||
(status.accessibilityEvents || status.aiScreenshot) &&
|
||||
status.keepAliveNeedsRecovery &&
|
||||
!_keepAliveStarting
|
||||
? () =>
|
||||
_monitorAccessibilityConnection(ensureKeepAlive: true)
|
||||
: null,
|
||||
child: Column(
|
||||
children: [
|
||||
JzSwitchTile(
|
||||
@@ -538,7 +617,12 @@ class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
|
||||
status.notificationEvents ||
|
||||
status.aiScreenshot) ...[
|
||||
const SizedBox(height: 10),
|
||||
_BackgroundKeepAliveCard(status: status),
|
||||
_BackgroundKeepAliveCard(
|
||||
status: status,
|
||||
starting: _keepAliveStarting,
|
||||
onStart: () =>
|
||||
_monitorAccessibilityConnection(ensureKeepAlive: true),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
_RecognitionCard(
|
||||
@@ -836,8 +920,14 @@ class _EvidencePill extends StatelessWidget {
|
||||
|
||||
class _BackgroundKeepAliveCard extends StatelessWidget {
|
||||
final RecognitionStatus status;
|
||||
final bool starting;
|
||||
final VoidCallback onStart;
|
||||
|
||||
const _BackgroundKeepAliveCard({required this.status});
|
||||
const _BackgroundKeepAliveCard({
|
||||
required this.status,
|
||||
required this.starting,
|
||||
required this.onStart,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -875,9 +965,18 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
status.batteryOptimizationIgnored ? '后台限制较少' : '需要设置',
|
||||
status.recentsProtectionActive
|
||||
? '任务清理防护中'
|
||||
: status.keepAliveRunning
|
||||
? '保护运行中'
|
||||
: status.keepAliveExpected
|
||||
? '保护未运行'
|
||||
: status.batteryOptimizationIgnored
|
||||
? '后台限制较少'
|
||||
: '需要设置',
|
||||
style: TextStyle(
|
||||
color: status.batteryOptimizationIgnored
|
||||
color:
|
||||
status.keepAliveRunning || status.recentsProtectionActive
|
||||
? AppTheme.primaryDeep
|
||||
: AppTheme.orange,
|
||||
fontSize: 10,
|
||||
@@ -888,15 +987,29 @@ class _BackgroundKeepAliveCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'无障碍和通知监听由 Android 独立轻量进程运行。请允许记之后台活动,'
|
||||
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
|
||||
'否则系统清理进程后可能暂时收不到支付事件。',
|
||||
status.recentsProtectionActive
|
||||
? 'OriginOS 最近任务保护已开启。记之不会显示在最近任务中,请从桌面图标重新打开;关闭全部识别后会自动恢复任务卡。'
|
||||
: '无障碍和通知监听由 Android 系统持续绑定。请允许记之后台活动,'
|
||||
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
|
||||
'否则系统清理进程后可能暂时收不到支付事件。',
|
||||
style: TextStyle(
|
||||
color: context.jz.text2,
|
||||
fontSize: 11.5,
|
||||
height: 1.55,
|
||||
),
|
||||
),
|
||||
if (status.keepAliveNeedsRecovery) ...[
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: JzActionButton(
|
||||
label: starting ? '正在启动…' : '启动后台保护',
|
||||
secondary: true,
|
||||
icon: const Icon(Icons.shield_outlined, size: 18),
|
||||
onPressed: starting ? null : onStart,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
@@ -932,6 +1045,10 @@ class _RecognitionCard extends StatelessWidget {
|
||||
final String? statusLabel;
|
||||
final Widget child;
|
||||
final VoidCallback? onOpenSettings;
|
||||
final VoidCallback? onRetry;
|
||||
final VoidCallback? onStartKeepAlive;
|
||||
final String? recoveryMessage;
|
||||
final String settingsLabel;
|
||||
|
||||
const _RecognitionCard({
|
||||
required this.icon,
|
||||
@@ -942,12 +1059,16 @@ class _RecognitionCard extends StatelessWidget {
|
||||
required this.child,
|
||||
this.statusLabel,
|
||||
this.onOpenSettings,
|
||||
this.onRetry,
|
||||
this.onStartKeepAlive,
|
||||
this.recoveryMessage,
|
||||
this.settingsLabel = '前往系统设置',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.jz;
|
||||
final color = connected ? AppTheme.primary : AppTheme.ai;
|
||||
final color = connected ? AppTheme.primary : AppTheme.orange;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -966,7 +1087,7 @@ class _RecognitionCard extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: connected
|
||||
? palette.primaryBackground
|
||||
: palette.aiBackground,
|
||||
: palette.warningBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 21),
|
||||
@@ -986,7 +1107,7 @@ class _RecognitionCard extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: connected
|
||||
? palette.primaryBackground
|
||||
: palette.aiBackground,
|
||||
: palette.warningBackground,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
@@ -1010,44 +1131,59 @@ class _RecognitionCard extends StatelessWidget {
|
||||
description,
|
||||
style: TextStyle(color: palette.text2, fontSize: 12, height: 1.55),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
child,
|
||||
if (onOpenSettings != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: '打开系统权限设置',
|
||||
child: InkWell(
|
||||
onTap: onOpenSettings,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.settings_outlined,
|
||||
size: 17,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'系统权限设置',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (recoveryMessage != null) ...[
|
||||
const SizedBox(height: 9),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: AppTheme.orange,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(
|
||||
recoveryMessage!,
|
||||
style: TextStyle(
|
||||
color: palette.text2,
|
||||
fontSize: 11.5,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
child,
|
||||
if (onOpenSettings != null ||
|
||||
onRetry != null ||
|
||||
onStartKeepAlive != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (onRetry != null)
|
||||
TextButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('重新检测'),
|
||||
),
|
||||
if (onStartKeepAlive != null)
|
||||
TextButton.icon(
|
||||
onPressed: onStartKeepAlive,
|
||||
icon: const Icon(Icons.shield_outlined, size: 18),
|
||||
label: const Text('启动后台保护'),
|
||||
),
|
||||
if (onOpenSettings != null)
|
||||
TextButton.icon(
|
||||
onPressed: onOpenSettings,
|
||||
icon: const Icon(Icons.settings_outlined, size: 18),
|
||||
label: Text(settingsLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/api/business_api.dart';
|
||||
@@ -6,6 +8,7 @@ import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
@@ -13,7 +16,10 @@ import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
enum _ReportKind { weekly, monthly, yearly }
|
||||
|
||||
class ReportPage extends StatefulWidget {
|
||||
const ReportPage({super.key});
|
||||
final String? initialPeriod;
|
||||
final DateTime? initialAnchor;
|
||||
|
||||
const ReportPage({super.key, this.initialPeriod, this.initialAnchor});
|
||||
|
||||
@override
|
||||
State<ReportPage> createState() => _ReportPageState();
|
||||
@@ -25,13 +31,20 @@ class _ReportPageState extends State<ReportPage> {
|
||||
String? _error;
|
||||
DateTime _anchor = ShanghaiTime.now;
|
||||
_ReportKind _kind = _ReportKind.monthly;
|
||||
int _loadRevision = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_anchor = widget.initialAnchor ?? ShanghaiTime.now;
|
||||
_kind = switch (widget.initialPeriod) {
|
||||
'week' => _ReportKind.weekly,
|
||||
'year' => _ReportKind.yearly,
|
||||
_ => _ReportKind.monthly,
|
||||
};
|
||||
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
|
||||
CurrentLedgerStore.instance.addListener(_load);
|
||||
_load();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -46,24 +59,33 @@ class _ReportPageState extends State<ReportPage> {
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final revision = ++_loadRevision;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final report = switch (_kind) {
|
||||
_ReportKind.weekly => await ReportApi.weekly(_anchor),
|
||||
_ReportKind.monthly => await ReportApi.monthlyPeriod(
|
||||
_anchor.year,
|
||||
_anchor.month,
|
||||
),
|
||||
_ReportKind.yearly => await ReportApi.yearly(_anchor.year),
|
||||
final period = switch (_kind) {
|
||||
_ReportKind.weekly => 'week',
|
||||
_ReportKind.monthly => 'month',
|
||||
_ReportKind.yearly => 'year',
|
||||
};
|
||||
if (mounted) setState(() => _report = report);
|
||||
final local = ReportApi.local(period, _anchor);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _report = local);
|
||||
}
|
||||
final remote = await ReportApi.remote(period, _anchor);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _report = remote);
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) setState(() => _error = apiErrorMessage(error));
|
||||
if (mounted && revision == _loadRevision && _report == null) {
|
||||
setState(() => _error = apiErrorMessage(error));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +130,7 @@ class _ReportPageState extends State<ReportPage> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(SessionStore.instance.aiEnabled ? 'AI 报告' : '报告'),
|
||||
actions: [BackendStatusIcon(onRetry: _load)],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:async';
|
||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/api/business_api.dart';
|
||||
import 'package:miaoji_zhang/shared/api/config_api.dart';
|
||||
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/async_error_view.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
|
||||
|
||||
class StatsPage extends StatefulWidget {
|
||||
@@ -23,12 +28,13 @@ class _StatsPageState extends State<StatsPage> {
|
||||
PeriodStats? _stats;
|
||||
String? _error;
|
||||
bool _loading = true;
|
||||
int _loadRevision = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
CurrentLedgerStore.instance.addListener(_load);
|
||||
_load();
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -38,6 +44,7 @@ class _StatsPageState extends State<StatsPage> {
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final revision = ++_loadRevision;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
@@ -45,12 +52,25 @@ class _StatsPageState extends State<StatsPage> {
|
||||
});
|
||||
}
|
||||
try {
|
||||
final stats = await TxApi.periodStats(_period, _anchor);
|
||||
if (mounted) setState(() => _stats = stats);
|
||||
final local = TxApi.periodStatsLocal(_period, _anchor);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _stats = local);
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) setState(() => _error = apiErrorMessage(error));
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _error = apiErrorMessage(error));
|
||||
}
|
||||
}
|
||||
try {
|
||||
final remote = await TxApi.periodStatsRemote(_period, _anchor);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _stats = remote);
|
||||
}
|
||||
} catch (_) {
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
if (mounted && revision == _loadRevision) {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +113,10 @@ class _StatsPageState extends State<StatsPage> {
|
||||
Widget build(BuildContext context) {
|
||||
final stats = _stats;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('统计')),
|
||||
appBar: AppBar(
|
||||
title: Text('统计'),
|
||||
actions: [BackendStatusIcon(onRetry: _load)],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
@@ -105,7 +128,7 @@ class _StatsPageState extends State<StatsPage> {
|
||||
JzOption(value: 'month', label: '月'),
|
||||
JzOption(value: 'year', label: '年'),
|
||||
],
|
||||
onChanged: _loading ? null : _selectPeriod,
|
||||
onChanged: _selectPeriod,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
@@ -141,8 +164,7 @@ class _StatsPageState extends State<StatsPage> {
|
||||
else ...[
|
||||
_categoryCard(stats),
|
||||
SizedBox(height: 10),
|
||||
if (stats.analysis case final analysis?)
|
||||
_analysisCard(analysis),
|
||||
_reportCard(stats.analysis),
|
||||
SizedBox(height: 10),
|
||||
_trendCard(stats),
|
||||
if (stats.byCategory.isNotEmpty) ...[
|
||||
@@ -171,7 +193,7 @@ class _StatsPageState extends State<StatsPage> {
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: '上一个周期',
|
||||
onPressed: _loading ? null : () => _shift(-1),
|
||||
onPressed: () => _shift(-1),
|
||||
icon: Icon(Icons.chevron_left_rounded),
|
||||
),
|
||||
SizedBox(
|
||||
@@ -184,9 +206,7 @@ class _StatsPageState extends State<StatsPage> {
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '下一个周期',
|
||||
onPressed: _loading || _isAfterCurrentPeriod(nextAnchor)
|
||||
? null
|
||||
: () => _shift(1),
|
||||
onPressed: _isAfterCurrentPeriod(nextAnchor) ? null : () => _shift(1),
|
||||
icon: Icon(Icons.chevron_right_rounded),
|
||||
),
|
||||
],
|
||||
@@ -300,41 +320,103 @@ class _StatsPageState extends State<StatsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _analysisCard(String text) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: context.jz.aiBackground,
|
||||
Widget _reportCard(String? analysis) {
|
||||
final aiEnabled = SessionStore.instance.aiEnabled;
|
||||
final accent = aiEnabled ? AppTheme.ai : AppTheme.primary;
|
||||
final background = aiEnabled
|
||||
? context.jz.aiBackground
|
||||
: context.jz.primaryBackground;
|
||||
final copy = analysis?.trim().isNotEmpty == true
|
||||
? analysis!.trim()
|
||||
: '查看本周期的收支变化、分类排行和消费高峰。';
|
||||
final route = Uri(
|
||||
path: '/report',
|
||||
queryParameters: {'period': _period, 'date': _anchor.toIso8601String()},
|
||||
).toString();
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: aiEnabled ? '查看 AI 报告' : '查看周期报告',
|
||||
child: Material(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppTheme.ai.withValues(alpha: 0.12)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: InkWell(
|
||||
onTap: () => context.push(route),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: context.jz.card,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: accent.withValues(alpha: 0.12)),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.auto_awesome_rounded,
|
||||
size: 16,
|
||||
color: AppTheme.ai,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: context.jz.card,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
aiEnabled
|
||||
? Icons.auto_awesome_rounded
|
||||
: Icons.assessment_outlined,
|
||||
size: 17,
|
||||
color: accent,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
aiEnabled
|
||||
? '${PublicConfigApi.companionName}的 AI 报告'
|
||||
: '周期报告',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: accent,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
Text(
|
||||
copy,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: context.jz.text2,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'查看完整报告',
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: accent,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 2),
|
||||
Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 17,
|
||||
color: accent,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: context.jz.text2,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
|
||||
enum BackendAvailability { unknown, online, offline }
|
||||
|
||||
class ApiClient {
|
||||
ApiClient._();
|
||||
static final ApiClient instance = ApiClient._();
|
||||
@@ -12,6 +14,9 @@ class ApiClient {
|
||||
static const _legacyTokenKey = 'auth_token';
|
||||
static final _tokenKey = 'auth_token_${BackendIdentity.scope}';
|
||||
static final sessionExpired = ValueNotifier<int>(0);
|
||||
static final availability = ValueNotifier<BackendAvailability>(
|
||||
BackendAvailability.unknown,
|
||||
);
|
||||
static bool _handlingUnauthorized = false;
|
||||
|
||||
static const String baseUrl = BackendIdentity.baseUrl;
|
||||
@@ -31,6 +36,10 @@ class ApiClient {
|
||||
)
|
||||
..interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onResponse: (response, handler) {
|
||||
availability.value = BackendAvailability.online;
|
||||
handler.next(response);
|
||||
},
|
||||
onRequest: (options, handler) async {
|
||||
final token = await _storage.read(key: _tokenKey);
|
||||
if (token != null) {
|
||||
@@ -39,6 +48,11 @@ class ApiClient {
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (error, handler) async {
|
||||
if (isConnectivityError(error)) {
|
||||
availability.value = BackendAvailability.offline;
|
||||
} else if (error.response != null) {
|
||||
availability.value = BackendAvailability.online;
|
||||
}
|
||||
final unauthorized = error.response?.statusCode == 401;
|
||||
final data = error.response?.data;
|
||||
final aiDenied =
|
||||
@@ -74,6 +88,18 @@ class ApiClient {
|
||||
}
|
||||
|
||||
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
||||
|
||||
Future<void> probe() async {
|
||||
try {
|
||||
await dio.get<void>(
|
||||
'/api/public/brand',
|
||||
options: Options(receiveTimeout: const Duration(seconds: 10)),
|
||||
);
|
||||
} catch (_) {
|
||||
// The interceptor owns the reachability state transition.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearToken() async {
|
||||
await _storage.delete(key: _tokenKey);
|
||||
await _storage.delete(key: _legacyTokenKey);
|
||||
@@ -91,8 +117,9 @@ String apiErrorMessage(Object e) {
|
||||
if (e is StateError) return e.message;
|
||||
if (e is DioException) {
|
||||
final data = e.response?.data;
|
||||
if (data is Map && data['message'] != null)
|
||||
if (data is Map && data['message'] != null) {
|
||||
return data['message'] as String;
|
||||
}
|
||||
if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.connectionError ||
|
||||
e.type == DioExceptionType.receiveTimeout) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
|
||||
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/local_export_service.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
@@ -19,6 +23,15 @@ class AiCompanion {
|
||||
roastLevel = json['roastLevel'] as int,
|
||||
stickerFrequency = json['stickerFrequency'] as int,
|
||||
proactiveLevel = json['proactiveLevel'] as int;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'avatarKey': avatarKey,
|
||||
'personaKey': personaKey,
|
||||
'customName': customName,
|
||||
'roastLevel': roastLevel,
|
||||
'stickerFrequency': stickerFrequency,
|
||||
'proactiveLevel': proactiveLevel,
|
||||
};
|
||||
}
|
||||
|
||||
class UserProfile {
|
||||
@@ -49,8 +62,9 @@ class UserProfile {
|
||||
required this.nickname,
|
||||
required this.appMode,
|
||||
required this.onboardingDone,
|
||||
this.aiCompanion,
|
||||
this.aiEnabled = false,
|
||||
}) : aiCompanion = null;
|
||||
});
|
||||
}
|
||||
|
||||
class AuthLoginResult {
|
||||
@@ -120,6 +134,20 @@ class AuthApi {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<AiCompanion?> cachedCompanion() async {
|
||||
final userId = SessionStore.instance.userId;
|
||||
if (userId == null) return null;
|
||||
final value = (await SharedPreferences.getInstance()).getString(
|
||||
_companionCacheKey(userId),
|
||||
);
|
||||
if (value == null) return null;
|
||||
try {
|
||||
return AiCompanion.fromJson(jsonDecode(value) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<UserProfile> completeOnboarding({
|
||||
required String appMode,
|
||||
required String avatarKey,
|
||||
@@ -272,13 +300,24 @@ class AuthApi {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _cacheProfile(UserProfile profile) =>
|
||||
SessionStore.instance.activateAccount(
|
||||
userId: profile.userId,
|
||||
username: profile.username,
|
||||
nickname: profile.nickname,
|
||||
appMode: profile.appMode,
|
||||
onboardingDone: profile.onboardingDone,
|
||||
aiEnabled: profile.aiEnabled,
|
||||
static Future<void> _cacheProfile(UserProfile profile) async {
|
||||
await SessionStore.instance.activateAccount(
|
||||
userId: profile.userId,
|
||||
username: profile.username,
|
||||
nickname: profile.nickname,
|
||||
appMode: profile.appMode,
|
||||
onboardingDone: profile.onboardingDone,
|
||||
aiEnabled: profile.aiEnabled,
|
||||
);
|
||||
final companion = profile.aiCompanion;
|
||||
if (companion != null) {
|
||||
await (await SharedPreferences.getInstance()).setString(
|
||||
_companionCacheKey(profile.userId),
|
||||
jsonEncode(companion.toJson()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static String _companionCacheKey(int userId) =>
|
||||
'ai_companion_${BackendIdentity.scope}_$userId';
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/local_database.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
|
||||
import 'package:miaoji_zhang/shared/services/sync_service.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class TxItem {
|
||||
@@ -244,6 +245,20 @@ class TxApi {
|
||||
static bool get _queueOfflineChanges =>
|
||||
SessionStore.instance.isAccount && SessionStore.instance.cloudSyncEnabled;
|
||||
|
||||
static MonthSummary monthLocal(int year, int month) => MonthSummary.fromJson(
|
||||
LocalDatabase.instance.monthSummary(year, month, _ledgerId),
|
||||
);
|
||||
|
||||
static Future<MonthSummary> monthRemote(int year, int month) async {
|
||||
final response = await _dio.get(
|
||||
'/api/transactions/month',
|
||||
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
|
||||
);
|
||||
final json = response.data as Map<String, dynamic>;
|
||||
LocalDatabase.instance.cacheMonthSummary(json);
|
||||
return MonthSummary.fromJson(json);
|
||||
}
|
||||
|
||||
static Future<MonthSummary> month(int year, int month) async {
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly) {
|
||||
@@ -330,13 +345,33 @@ class TxApi {
|
||||
}
|
||||
}
|
||||
|
||||
static PeriodStats periodStatsLocal(String period, DateTime anchor) =>
|
||||
PeriodStats.fromJson(
|
||||
LocalDatabase.instance.periodStats(period, anchor, _ledgerId),
|
||||
);
|
||||
|
||||
static Future<PeriodStats> periodStatsRemote(
|
||||
String period,
|
||||
DateTime anchor,
|
||||
) async {
|
||||
final response = await _dio.get(
|
||||
'/api/transactions/stats/period',
|
||||
queryParameters: {
|
||||
'period': period,
|
||||
'anchor':
|
||||
'${anchor.year.toString().padLeft(4, '0')}-'
|
||||
'${anchor.month.toString().padLeft(2, '0')}-'
|
||||
'${anchor.day.toString().padLeft(2, '0')}',
|
||||
'ledgerId': _ledgerId,
|
||||
},
|
||||
);
|
||||
return PeriodStats.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
static Future<List<CategoryItem>> categories(String type) async {
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly) {
|
||||
return LocalDatabase.instance
|
||||
.categories(type)
|
||||
.map(CategoryItem.fromJson)
|
||||
.toList();
|
||||
return categoriesLocal(type);
|
||||
}
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
@@ -350,13 +385,32 @@ class TxApi {
|
||||
.toList();
|
||||
} catch (error) {
|
||||
if (!isConnectivityError(error) || !session.isAccount) rethrow;
|
||||
return LocalDatabase.instance
|
||||
.categories(type)
|
||||
.map(CategoryItem.fromJson)
|
||||
.toList();
|
||||
return categoriesLocal(type);
|
||||
}
|
||||
}
|
||||
|
||||
static List<CategoryItem> categoriesLocal(String type) {
|
||||
LocalDatabase.instance.ensureDefaultCategories(type: type);
|
||||
return LocalDatabase.instance
|
||||
.categories(type)
|
||||
.map(CategoryItem.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Future<List<CategoryItem>> categoriesRemote(String type) async {
|
||||
final response = await _dio.get(
|
||||
'/api/categories',
|
||||
queryParameters: {'type': type},
|
||||
);
|
||||
final values = response.data as List;
|
||||
LocalDatabase.instance.cacheCategories(values, replaceType: type);
|
||||
LocalDatabase.instance.ensureDefaultCategories(type: type);
|
||||
return LocalDatabase.instance
|
||||
.categories(type)
|
||||
.map(CategoryItem.fromJson)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Future<TxItem> create({
|
||||
required int categoryId,
|
||||
required String type,
|
||||
@@ -374,6 +428,7 @@ class TxApi {
|
||||
String? recognitionOccurrenceId,
|
||||
String? evidenceFingerprint,
|
||||
String? recognitionConfidence,
|
||||
bool localFirst = false,
|
||||
}) async {
|
||||
final payload = <String, dynamic>{
|
||||
'ledgerId': _ledgerId,
|
||||
@@ -401,7 +456,10 @@ class TxApi {
|
||||
'recognitionConfidence': recognitionConfidence,
|
||||
};
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || categoryId < 0 || _ledgerId < 0) {
|
||||
if (localFirst ||
|
||||
session.shouldUseLocalOnly ||
|
||||
categoryId < 0 ||
|
||||
_ledgerId < 0) {
|
||||
final local = LocalDatabase.instance.createTransaction(payload);
|
||||
if (_queueOfflineChanges) {
|
||||
LocalDatabase.instance.enqueueSync(
|
||||
@@ -410,6 +468,7 @@ class TxApi {
|
||||
'create',
|
||||
payload,
|
||||
);
|
||||
_scheduleBackgroundSync();
|
||||
}
|
||||
return TxItem.fromJson(local);
|
||||
}
|
||||
@@ -532,7 +591,9 @@ class TxApi {
|
||||
includeDeleted: true,
|
||||
)?['updatedAt'];
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || id < 0) {
|
||||
if (session.shouldUseLocalOnly ||
|
||||
ApiClient.availability.value == BackendAvailability.offline ||
|
||||
id < 0) {
|
||||
LocalDatabase.instance.softDeleteTransaction(id);
|
||||
if (_queueOfflineChanges) {
|
||||
LocalDatabase.instance.enqueueSync('transaction', id, 'delete', {
|
||||
@@ -613,7 +674,8 @@ class TxApi {
|
||||
|
||||
static Future<List<TxItem>> recycleBin() async {
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly) {
|
||||
if (session.shouldUseLocalOnly ||
|
||||
ApiClient.availability.value == BackendAvailability.offline) {
|
||||
return LocalDatabase.instance
|
||||
.recycleBin(_ledgerId)
|
||||
.map(TxItem.fromJson)
|
||||
@@ -638,13 +700,32 @@ class TxApi {
|
||||
}
|
||||
}
|
||||
|
||||
static List<TxItem> recycleBinLocal() => LocalDatabase.instance
|
||||
.recycleBin(_ledgerId)
|
||||
.map(TxItem.fromJson)
|
||||
.toList();
|
||||
|
||||
static Future<List<TxItem>> recycleBinRemote() async {
|
||||
final response = await _dio.get(
|
||||
'/api/transactions/recycle-bin',
|
||||
queryParameters: {'ledgerId': _ledgerId},
|
||||
);
|
||||
final values = response.data as List;
|
||||
LocalDatabase.instance.cacheTransactions(values);
|
||||
return values
|
||||
.map((item) => TxItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Future<TxItem> restore(int id) async {
|
||||
final baseUpdatedAt = LocalDatabase.instance.transaction(
|
||||
id,
|
||||
includeDeleted: true,
|
||||
)?['updatedAt'];
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || id < 0) {
|
||||
if (session.shouldUseLocalOnly ||
|
||||
ApiClient.availability.value == BackendAvailability.offline ||
|
||||
id < 0) {
|
||||
final local = LocalDatabase.instance.restoreTransaction(id);
|
||||
if (_queueOfflineChanges) {
|
||||
LocalDatabase.instance.enqueueSync('transaction', id, 'restore', {
|
||||
@@ -984,6 +1065,18 @@ class BudgetApi {
|
||||
static final _dio = ApiClient.instance.dio;
|
||||
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
|
||||
|
||||
static BudgetsData getLocal(int year, int month) => BudgetsData.fromJson(
|
||||
LocalDatabase.instance.budgets(year, month, _ledgerId),
|
||||
);
|
||||
|
||||
static Future<BudgetsData> getRemote(int year, int month) async {
|
||||
final response = await _dio.get(
|
||||
'/api/budgets',
|
||||
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
|
||||
);
|
||||
return BudgetsData.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
static Future<BudgetsData> get(int year, int month) async {
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly) {
|
||||
@@ -1294,6 +1387,29 @@ class ReportApi {
|
||||
static final _dio = ApiClient.instance.dio;
|
||||
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
|
||||
|
||||
static PeriodReport local(String period, DateTime anchor) =>
|
||||
_localPeriod(period, anchor);
|
||||
|
||||
static Future<PeriodReport> remote(String period, DateTime anchor) async {
|
||||
if (period == 'month') {
|
||||
return PeriodReport.fromMonthly(await monthly(anchor.year, anchor.month));
|
||||
}
|
||||
final path = period == 'week'
|
||||
? '/api/reports/weekly'
|
||||
: '/api/reports/yearly';
|
||||
final query = period == 'week'
|
||||
? {
|
||||
'date':
|
||||
'${anchor.year.toString().padLeft(4, '0')}-'
|
||||
'${anchor.month.toString().padLeft(2, '0')}-'
|
||||
'${anchor.day.toString().padLeft(2, '0')}',
|
||||
'ledgerId': _ledgerId,
|
||||
}
|
||||
: {'year': anchor.year, 'ledgerId': _ledgerId};
|
||||
final response = await _dio.get(path, queryParameters: query);
|
||||
return PeriodReport.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
static Future<PeriodReport> weekly(DateTime date) async {
|
||||
if (SessionStore.instance.shouldUseLocalOnly) {
|
||||
return _localPeriod('week', date);
|
||||
@@ -1586,8 +1702,9 @@ class CategoryApi {
|
||||
String name,
|
||||
String iconKey,
|
||||
String colorKey,
|
||||
String type,
|
||||
) async {
|
||||
String type, {
|
||||
bool localFirst = false,
|
||||
}) async {
|
||||
final payload = {
|
||||
'name': name,
|
||||
'iconKey': iconKey,
|
||||
@@ -1595,7 +1712,9 @@ class CategoryApi {
|
||||
'type': type,
|
||||
};
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly) {
|
||||
if (localFirst ||
|
||||
session.shouldUseLocalOnly ||
|
||||
ApiClient.availability.value == BackendAvailability.offline) {
|
||||
final local = LocalDatabase.instance.createCategory(
|
||||
name,
|
||||
iconKey,
|
||||
@@ -1609,6 +1728,7 @@ class CategoryApi {
|
||||
'create',
|
||||
payload,
|
||||
);
|
||||
_scheduleBackgroundSync();
|
||||
}
|
||||
return CategoryItem.fromJson(local);
|
||||
}
|
||||
@@ -1641,6 +1761,7 @@ class CategoryApi {
|
||||
required String iconKey,
|
||||
required String colorKey,
|
||||
required int sortOrder,
|
||||
bool localFirst = false,
|
||||
}) async {
|
||||
final payload = {
|
||||
'name': name,
|
||||
@@ -1649,7 +1770,10 @@ class CategoryApi {
|
||||
'sortOrder': sortOrder,
|
||||
};
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || id < 0) {
|
||||
if (localFirst ||
|
||||
session.shouldUseLocalOnly ||
|
||||
ApiClient.availability.value == BackendAvailability.offline ||
|
||||
id < 0) {
|
||||
final local = LocalDatabase.instance.updateCategory(
|
||||
id,
|
||||
name,
|
||||
@@ -1659,45 +1783,90 @@ class CategoryApi {
|
||||
);
|
||||
if (session.isAccount && session.cloudSyncEnabled) {
|
||||
LocalDatabase.instance.enqueueSync('category', id, 'update', payload);
|
||||
_scheduleBackgroundSync();
|
||||
}
|
||||
return CategoryItem.fromJson(local);
|
||||
}
|
||||
final response = await _dio.put('/api/categories/$id', data: payload);
|
||||
final json = response.data as Map<String, dynamic>;
|
||||
LocalDatabase.instance.cacheCategories([json]);
|
||||
return CategoryItem.fromJson(json);
|
||||
try {
|
||||
final response = await _dio.put('/api/categories/$id', data: payload);
|
||||
final json = response.data as Map<String, dynamic>;
|
||||
LocalDatabase.instance.cacheCategories([json]);
|
||||
return CategoryItem.fromJson(json);
|
||||
} catch (error) {
|
||||
if (!isConnectivityError(error) || !session.isAccount) rethrow;
|
||||
final local = LocalDatabase.instance.updateCategory(
|
||||
id,
|
||||
name,
|
||||
iconKey,
|
||||
colorKey,
|
||||
sortOrder,
|
||||
);
|
||||
LocalDatabase.instance.enqueueSync('category', id, 'update', payload);
|
||||
return CategoryItem.fromJson(local);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> reorder(String type, List<int> categoryIds) async {
|
||||
static Future<void> reorder(
|
||||
String type,
|
||||
List<int> categoryIds, {
|
||||
bool localFirst = false,
|
||||
}) async {
|
||||
LocalDatabase.instance.reorderCategories(type, categoryIds);
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || categoryIds.any((id) => id < 0)) {
|
||||
if (localFirst ||
|
||||
session.shouldUseLocalOnly ||
|
||||
ApiClient.availability.value == BackendAvailability.offline ||
|
||||
categoryIds.any((id) => id < 0)) {
|
||||
if (session.isAccount && session.cloudSyncEnabled) {
|
||||
LocalDatabase.instance.enqueueSync('category', 0, 'reorder', {
|
||||
'type': type,
|
||||
'categoryIds': categoryIds,
|
||||
});
|
||||
_scheduleBackgroundSync();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await _dio.put(
|
||||
'/api/categories/reorder',
|
||||
data: {'type': type, 'categoryIds': categoryIds},
|
||||
);
|
||||
try {
|
||||
await _dio.put(
|
||||
'/api/categories/reorder',
|
||||
data: {'type': type, 'categoryIds': categoryIds},
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isConnectivityError(error) || !session.isAccount) rethrow;
|
||||
LocalDatabase.instance.enqueueSync('category', 0, 'reorder', {
|
||||
'type': type,
|
||||
'categoryIds': categoryIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> delete(int id) async {
|
||||
static Future<void> delete(int id, {bool localFirst = false}) async {
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || id < 0) {
|
||||
if (localFirst ||
|
||||
session.shouldUseLocalOnly ||
|
||||
ApiClient.availability.value == BackendAvailability.offline ||
|
||||
id < 0) {
|
||||
LocalDatabase.instance.deleteCategory(id);
|
||||
if (session.isAccount && session.cloudSyncEnabled) {
|
||||
LocalDatabase.instance.enqueueSync('category', id, 'delete', {
|
||||
'id': id,
|
||||
});
|
||||
_scheduleBackgroundSync();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await _dio.delete('/api/categories/$id');
|
||||
LocalDatabase.instance.deleteCategory(id);
|
||||
try {
|
||||
await _dio.delete('/api/categories/$id');
|
||||
LocalDatabase.instance.deleteCategory(id);
|
||||
} catch (error) {
|
||||
if (!isConnectivityError(error) || !session.isAccount) rethrow;
|
||||
LocalDatabase.instance.deleteCategory(id);
|
||||
LocalDatabase.instance.enqueueSync('category', id, 'delete', {'id': id});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleBackgroundSync() {
|
||||
SyncService.instance.refreshLocalStatus();
|
||||
unawaited(SyncService.instance.run());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
|
||||
class BrandConfig {
|
||||
@@ -41,6 +45,13 @@ class AvatarItem {
|
||||
defaultName = j['defaultName'] as String,
|
||||
speechTic = j['speechTic'] as String? ?? '',
|
||||
imageUrl = j['imageUrl'] as String?;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'key': key,
|
||||
'defaultName': defaultName,
|
||||
'speechTic': speechTic,
|
||||
'imageUrl': imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
class PersonaItem {
|
||||
@@ -50,6 +61,13 @@ class PersonaItem {
|
||||
name = j['name'] as String,
|
||||
description = j['description'] as String? ?? '',
|
||||
sampleLine = j['sampleLine'] as String? ?? '';
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'key': key,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'sampleLine': sampleLine,
|
||||
};
|
||||
}
|
||||
|
||||
class CompanionDisplay {
|
||||
@@ -145,16 +163,59 @@ class PublicConfigApi {
|
||||
|
||||
static Future<List<AvatarItem>> avatars() async {
|
||||
final res = await _dio.get('/api/public/avatars');
|
||||
return (res.data as List)
|
||||
final values = (res.data as List)
|
||||
.map((e) => AvatarItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
await _cacheCatalog(
|
||||
_avatarCacheKey,
|
||||
values.map((e) => e.toJson()).toList(),
|
||||
);
|
||||
return values;
|
||||
}
|
||||
|
||||
static Future<List<PersonaItem>> personas() async {
|
||||
final res = await _dio.get('/api/public/personas');
|
||||
return (res.data as List)
|
||||
final values = (res.data as List)
|
||||
.map((e) => PersonaItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
await _cacheCatalog(
|
||||
_personaCacheKey,
|
||||
values.map((e) => e.toJson()).toList(),
|
||||
);
|
||||
return values;
|
||||
}
|
||||
|
||||
static Future<List<AvatarItem>> cachedAvatars() async =>
|
||||
(await _cachedCatalog(_avatarCacheKey)).map(AvatarItem.fromJson).toList();
|
||||
|
||||
static Future<List<PersonaItem>> cachedPersonas() async =>
|
||||
(await _cachedCatalog(
|
||||
_personaCacheKey,
|
||||
)).map(PersonaItem.fromJson).toList();
|
||||
|
||||
static String get _avatarCacheKey =>
|
||||
'public_avatars_${BackendIdentity.scope}';
|
||||
static String get _personaCacheKey =>
|
||||
'public_personas_${BackendIdentity.scope}';
|
||||
|
||||
static Future<void> _cacheCatalog(
|
||||
String key,
|
||||
List<Map<String, dynamic>> values,
|
||||
) async {
|
||||
await (await SharedPreferences.getInstance()).setString(
|
||||
key,
|
||||
jsonEncode(values),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> _cachedCatalog(String key) async {
|
||||
final value = (await SharedPreferences.getInstance()).getString(key);
|
||||
if (value == null) return const [];
|
||||
try {
|
||||
return (jsonDecode(value) as List).cast<Map<String, dynamic>>().toList();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
static String? _normalizeName(String? value) {
|
||||
|
||||
@@ -52,6 +52,17 @@ class CurrentLedgerStore extends ChangeNotifier {
|
||||
return _loading ??= _loadCached().whenComplete(() => _loading = null);
|
||||
}
|
||||
|
||||
Future<void> refreshRemote() async {
|
||||
if (!SessionStore.instance.isAccount ||
|
||||
SessionStore.instance.shouldUseLocalOnly) {
|
||||
return;
|
||||
}
|
||||
final response = await _dio.get('/api/ledgers');
|
||||
final values = response.data as List;
|
||||
LocalDatabase.instance.cacheLedgers(values);
|
||||
_apply(values);
|
||||
}
|
||||
|
||||
Future<void> _loadCached() async {
|
||||
_apply(LocalDatabase.instance.ledgers());
|
||||
}
|
||||
@@ -78,11 +89,24 @@ class CurrentLedgerStore extends ChangeNotifier {
|
||||
final items = values
|
||||
.map((item) => LedgerInfo.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
_ledgers = items;
|
||||
_current =
|
||||
final current =
|
||||
items.where((item) => item.isDefault).firstOrNull ??
|
||||
(items.isEmpty ? null : items.first);
|
||||
notifyListeners();
|
||||
final changed =
|
||||
_current?.id != current?.id ||
|
||||
_ledgers.length != items.length ||
|
||||
Iterable<int>.generate(items.length).any((index) {
|
||||
final before = _ledgers[index];
|
||||
final after = items[index];
|
||||
return before.id != after.id ||
|
||||
before.name != after.name ||
|
||||
before.iconKey != after.iconKey ||
|
||||
before.isDefault != after.isDefault ||
|
||||
before.transactionCount != after.transactionCount;
|
||||
});
|
||||
_ledgers = items;
|
||||
_current = current;
|
||||
if (changed) notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> select(int id) async {
|
||||
|
||||
@@ -39,15 +39,17 @@ class GuestMergeService {
|
||||
: (existingLedger['id'] as num).toInt();
|
||||
final categoryMap = <int, int>{};
|
||||
final available = <Map<String, dynamic>>[];
|
||||
for (final type in ['expense', 'income']) {
|
||||
for (final type in ['expense', 'income']) {
|
||||
final response = await _dio.get(
|
||||
'/api/categories',
|
||||
queryParameters: {'type': type},
|
||||
);
|
||||
available.addAll(
|
||||
(response.data as List).map((item) => item as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
await _ensureFallbackCategory(available, 'expense');
|
||||
await _ensureFallbackCategory(available, 'income');
|
||||
|
||||
for (final value in snapshot['categories'] as List<dynamic>? ?? const []) {
|
||||
final category = value as Map<String, dynamic>;
|
||||
@@ -90,9 +92,10 @@ class GuestMergeService {
|
||||
item['name'] == transaction['categoryName'],
|
||||
);
|
||||
if (exact.isNotEmpty) return (exact.first['id'] as num).toInt();
|
||||
final fallback = available.firstWhere(
|
||||
(item) => item['type'] == categoryType && item['name'] == '其他',
|
||||
);
|
||||
final fallback = available.firstWhere(
|
||||
(item) => item['type'] == categoryType && item['name'] == '其他',
|
||||
orElse: () => throw StateError('$categoryType 分类缺少“其他”,无法导入本机账单'),
|
||||
);
|
||||
return (fallback['id'] as num).toInt();
|
||||
}
|
||||
|
||||
@@ -145,13 +148,47 @@ class GuestMergeService {
|
||||
|
||||
await CurrentLedgerStore.instance.ensureLoaded(force: true);
|
||||
await CurrentLedgerStore.instance.select(ledgerId);
|
||||
return GuestMergeResult(
|
||||
ledgerId: ledgerId,
|
||||
transactionCount: transactionCount,
|
||||
);
|
||||
}
|
||||
|
||||
static int? _findMappedDefault(
|
||||
return GuestMergeResult(
|
||||
ledgerId: ledgerId,
|
||||
transactionCount: transactionCount,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _ensureFallbackCategory(
|
||||
List<Map<String, dynamic>> available,
|
||||
String type,
|
||||
) async {
|
||||
bool hasFallback() => available.any(
|
||||
(item) => item['type'] == type && item['name'] == '其他',
|
||||
);
|
||||
if (hasFallback()) return;
|
||||
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/api/categories',
|
||||
data: {
|
||||
'name': '其他',
|
||||
'iconKey': 'tag',
|
||||
'colorKey': type == 'income' ? 'lime' : 'graphite',
|
||||
'type': type,
|
||||
},
|
||||
);
|
||||
available.add(response.data as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
// Another request may have created it between the list and create calls.
|
||||
final response = await _dio.get(
|
||||
'/api/categories',
|
||||
queryParameters: {'type': type},
|
||||
);
|
||||
available.removeWhere((item) => item['type'] == type);
|
||||
available.addAll(
|
||||
(response.data as List).map((item) => item as Map<String, dynamic>),
|
||||
);
|
||||
if (!hasFallback()) rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static int? _findMappedDefault(
|
||||
List<Map<String, dynamic>> available,
|
||||
Map<String, dynamic> snapshot,
|
||||
int oldCategoryId,
|
||||
|
||||
@@ -16,12 +16,12 @@ class LocalDatabase {
|
||||
static const _secureStorage = FlutterSecureStorage();
|
||||
|
||||
@visibleForTesting
|
||||
static LocalDatabase inMemoryForTesting() {
|
||||
static LocalDatabase inMemoryForTesting({bool seedDefaults = true}) {
|
||||
final database = LocalDatabase._();
|
||||
database._database = sqlite3.openInMemory();
|
||||
database._namespace = 'test';
|
||||
database._migrate();
|
||||
database._seedDefaults();
|
||||
if (seedDefaults) database._seedDefaults();
|
||||
return database;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,11 @@ class LocalDatabase {
|
||||
_database = database;
|
||||
_namespace = namespace;
|
||||
_migrate();
|
||||
if (namespace == 'guest') _seedDefaults();
|
||||
if (namespace == 'guest') {
|
||||
_seedDefaults();
|
||||
} else {
|
||||
ensureDefaultCategories();
|
||||
}
|
||||
} catch (_) {
|
||||
database.close();
|
||||
rethrow;
|
||||
@@ -295,6 +299,80 @@ class LocalDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures an account can still render the category picker and create a
|
||||
/// manual transaction before its first successful category refresh.
|
||||
///
|
||||
/// Account fallback IDs mirror the server's seeded system categories. The
|
||||
/// remote refresh remains authoritative and will update these rows in place.
|
||||
void ensureDefaultCategories({String? type}) {
|
||||
final isGuestNamespace = _namespace == 'guest';
|
||||
const expense = <(int, String, String, String, String, int)>[
|
||||
(1, '餐饮', 'food', 'coral', 'expense', 0),
|
||||
(2, '饮品', 'cup', 'cyan', 'expense', 1),
|
||||
(3, '购物', 'cart', 'blue', 'expense', 2),
|
||||
(4, '交通', 'metro', 'teal', 'expense', 3),
|
||||
(5, '住房', 'house', 'sand', 'expense', 4),
|
||||
(6, '娱乐', 'game', 'violet', 'expense', 5),
|
||||
(7, '医疗', 'pill', 'red', 'expense', 6),
|
||||
(8, '学习', 'book', 'indigo', 'expense', 7),
|
||||
(9, '服饰', 'shirt', 'plum', 'expense', 8),
|
||||
(10, '人情', 'gift', 'rose', 'expense', 9),
|
||||
(11, '旅行', 'plane', 'sky', 'expense', 10),
|
||||
(12, '其他', 'tag', 'graphite', 'expense', 11),
|
||||
];
|
||||
const accountIncome = <(int, String, String, String, String, int)>[
|
||||
(13, '工资', 'money', 'mint', 'income', 0),
|
||||
(14, '兼职', 'briefcase', 'forest', 'income', 1),
|
||||
(15, '理财', 'chart', 'navy', 'income', 2),
|
||||
(16, '红包', 'gift', 'orange', 'income', 3),
|
||||
(17, '报销', 'card', 'amber', 'income', 4),
|
||||
(18, '奖金', 'sparkle', 'aqua', 'income', 5),
|
||||
(19, '其他', 'tag', 'lime', 'income', 6),
|
||||
];
|
||||
const guestIncome = <(int, String, String, String, String, int)>[
|
||||
(101, '工资', 'money', 'mint', 'income', 10),
|
||||
(102, '兼职', 'parttime', 'forest', 'income', 20),
|
||||
(103, '理财', 'invest', 'navy', 'income', 30),
|
||||
(104, '红包', 'redpacket', 'orange', 'income', 40),
|
||||
(105, '报销', 'reimburse', 'amber', 'income', 50),
|
||||
(106, '奖金', 'bonus', 'aqua', 'income', 60),
|
||||
(107, '其他', 'tag', 'lime', 'income', 70),
|
||||
];
|
||||
final catalogs = <String, List<(int, String, String, String, String, int)>>{
|
||||
'expense': expense,
|
||||
'income': isGuestNamespace ? guestIncome : accountIncome,
|
||||
};
|
||||
final now = DateTime.now().toUtc().toIso8601String();
|
||||
for (final entry in catalogs.entries) {
|
||||
if (type != null && entry.key != type) continue;
|
||||
final active = _db.select(
|
||||
'''SELECT 1 FROM categories
|
||||
WHERE type = ? AND is_deleted = 0 AND is_custom = 0 LIMIT 1''',
|
||||
[entry.key],
|
||||
);
|
||||
if (active.isNotEmpty) continue;
|
||||
for (final item in entry.value) {
|
||||
_db.execute(
|
||||
'''
|
||||
INSERT INTO categories
|
||||
(id, name, icon_key, color_key, type, sort_order, is_custom, is_deleted, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
icon_key = excluded.icon_key,
|
||||
color_key = excluded.color_key,
|
||||
type = excluded.type,
|
||||
sort_order = excluded.sort_order,
|
||||
is_custom = 0,
|
||||
is_deleted = 0,
|
||||
updated_at = excluded.updated_at
|
||||
''',
|
||||
[item.$1, item.$2, item.$3, item.$4, item.$5, item.$6, now],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> guestSnapshot() {
|
||||
final customCategories = _db
|
||||
.select('''
|
||||
|
||||
@@ -49,6 +49,7 @@ class RecognitionDiagnosticDisplay {
|
||||
'matched' || 'auto_ready' => '已识别',
|
||||
'confirm' => '待确认',
|
||||
'started' => '处理中',
|
||||
'waiting' => '等待结果页',
|
||||
'failed' => '失败',
|
||||
_ => '未触发入账',
|
||||
};
|
||||
@@ -56,6 +57,7 @@ class RecognitionDiagnosticDisplay {
|
||||
|
||||
static String _summaryLabel(String result, String reason) {
|
||||
if (reason == 'duplicate_result_surface') return '已合并';
|
||||
if (result == 'waiting') return '等待结果页';
|
||||
if (_captureFailureReasons.contains(reason)) return '截图失败';
|
||||
if (_ocrNoResultReasons.contains(reason)) return 'OCR 无结果';
|
||||
if (_ruleRejectedReasons.contains(reason) || result == 'rejected') {
|
||||
@@ -65,6 +67,7 @@ class RecognitionDiagnosticDisplay {
|
||||
'matched' || 'auto_ready' => '已识别',
|
||||
'confirm' => '待确认',
|
||||
'started' => '处理中',
|
||||
'waiting' => '等待结果页',
|
||||
'failed' => '失败',
|
||||
_ => '没事件',
|
||||
};
|
||||
@@ -75,12 +78,16 @@ class RecognitionDiagnosticDisplay {
|
||||
'history_page' => '当前是账单或交易历史页',
|
||||
'blocked_status' => '当前状态为失败、处理中或已取消',
|
||||
'no_text' => '截图中没有识别到文字',
|
||||
'no_success_status' => '没有找到明确或弱完成状态,可开启诊断预览查看脱敏结果',
|
||||
'payment_input_page' => '当前仍是付款输入或确认页面,已拒绝入账',
|
||||
'no_success_status' => '暂未找到完成状态,正在按计划复核结果页',
|
||||
'payment_input_page' => '当前仍是付款输入或确认页面,等待结果页,不会提前入账',
|
||||
'result_page_timeout' => '90 秒内未等到明确结果页,本次流程已停止追踪',
|
||||
'direction_unknown' => '识别到完成状态,但无法确认收支方向',
|
||||
'missing_amount' => '成功状态已识别,但没有找到金额',
|
||||
'expected_amount_missing' => '结果页没有金额,且付款前金额不唯一或未捕获',
|
||||
'expected_amount_fallback' => '结果页金额缺失,已使用付款前确认的唯一金额',
|
||||
'expected_amount_fallback' => '结果页未读到金额,已使用本次付款流程确认的金额',
|
||||
'weak_expected_fallback' => '结果页未读到金额,付款前金额来源不可靠,请确认后入账',
|
||||
'result_amount_conflict' => '结果页金额与本次付款金额不一致,请确认后入账',
|
||||
'amount_source_untrusted' => '金额无法与当前付款流程可靠关联,请确认后入账',
|
||||
'red_packet_not_settled' => '红包尚未明确到账或退回,不会自动入账',
|
||||
'duplicate_result_surface' => '同一结果页已处理,本次刷新已忽略',
|
||||
'weak_status_confirm' => '只识别到弱完成状态,组合证据不足,需确认后入账',
|
||||
@@ -123,7 +130,7 @@ class RecognitionDiagnosticDisplay {
|
||||
|
||||
static String? _amountSourceLabel(String? value) {
|
||||
return switch (value) {
|
||||
'expected' => '使用付款前金额',
|
||||
'expected' => '使用本次付款金额',
|
||||
'result' => '使用结果页金额',
|
||||
_ => null,
|
||||
};
|
||||
@@ -164,13 +171,15 @@ class RecognitionDiagnosticDisplay {
|
||||
static const _ruleRejectedReasons = {
|
||||
'history_page',
|
||||
'blocked_status',
|
||||
'no_success_status',
|
||||
'payment_input_page',
|
||||
'result_page_timeout',
|
||||
'direction_unknown',
|
||||
'missing_amount',
|
||||
'expected_amount_missing',
|
||||
'red_packet_not_settled',
|
||||
'weak_status_confirm',
|
||||
'ambiguous_or_unarmed',
|
||||
'weak_expected_fallback',
|
||||
'result_amount_conflict',
|
||||
'amount_source_untrusted',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,9 +62,47 @@ class RecognitionDiagnostic {
|
||||
}
|
||||
}
|
||||
|
||||
enum AccessibilityConnectionState {
|
||||
unauthorized,
|
||||
reconnecting,
|
||||
connected,
|
||||
disconnected;
|
||||
|
||||
static AccessibilityConnectionState parse(
|
||||
Object? value, {
|
||||
required bool authorized,
|
||||
required bool connected,
|
||||
}) {
|
||||
return switch (value?.toString()) {
|
||||
'reconnecting' => AccessibilityConnectionState.reconnecting,
|
||||
'connected' => AccessibilityConnectionState.connected,
|
||||
'disconnected' => AccessibilityConnectionState.disconnected,
|
||||
'unauthorized' => AccessibilityConnectionState.unauthorized,
|
||||
_ =>
|
||||
!authorized
|
||||
? unauthorized
|
||||
: connected
|
||||
? AccessibilityConnectionState.connected
|
||||
: disconnected,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class RecognitionStatus {
|
||||
final bool accessibilityAuthorized;
|
||||
final bool accessibilityConnected;
|
||||
final AccessibilityConnectionState accessibilityConnectionState;
|
||||
final DateTime? accessibilityLastConnectedAt;
|
||||
final DateTime? accessibilityLastDisconnectedAt;
|
||||
final DateTime? recognitionProcessStartedAt;
|
||||
final bool keepAliveExpected;
|
||||
final bool keepAliveRunning;
|
||||
final String? keepAliveError;
|
||||
final bool recentsProtectionExpected;
|
||||
final bool recentsProtectionActive;
|
||||
final DateTime? lastRecognitionExitAt;
|
||||
final String? lastRecognitionExitReason;
|
||||
final bool taskCleanerRecoveryNeeded;
|
||||
final bool notificationAuthorized;
|
||||
final bool notificationConnected;
|
||||
final bool postNotificationsGranted;
|
||||
@@ -81,6 +119,19 @@ class RecognitionStatus {
|
||||
const RecognitionStatus({
|
||||
required this.accessibilityAuthorized,
|
||||
required this.accessibilityConnected,
|
||||
this.accessibilityConnectionState =
|
||||
AccessibilityConnectionState.unauthorized,
|
||||
this.accessibilityLastConnectedAt,
|
||||
this.accessibilityLastDisconnectedAt,
|
||||
this.recognitionProcessStartedAt,
|
||||
this.keepAliveExpected = false,
|
||||
this.keepAliveRunning = false,
|
||||
this.keepAliveError,
|
||||
this.recentsProtectionExpected = false,
|
||||
this.recentsProtectionActive = false,
|
||||
this.lastRecognitionExitAt,
|
||||
this.lastRecognitionExitReason,
|
||||
this.taskCleanerRecoveryNeeded = false,
|
||||
required this.notificationAuthorized,
|
||||
required this.notificationConnected,
|
||||
required this.postNotificationsGranted,
|
||||
@@ -101,10 +152,39 @@ class RecognitionStatus {
|
||||
final settings = rawSettings == null || rawSettings.isEmpty
|
||||
? const <String, dynamic>{}
|
||||
: jsonDecode(rawSettings) as Map<String, dynamic>;
|
||||
final accessibilityAuthorized =
|
||||
value['accessibilityAuthorized'] as bool? ?? false;
|
||||
final accessibilityConnected =
|
||||
value['accessibilityConnected'] as bool? ?? false;
|
||||
DateTime? epochDate(String key) {
|
||||
final epoch = (value[key] as num?)?.toInt() ?? 0;
|
||||
return epoch <= 0 ? null : DateTime.fromMillisecondsSinceEpoch(epoch);
|
||||
}
|
||||
|
||||
return RecognitionStatus(
|
||||
accessibilityAuthorized:
|
||||
value['accessibilityAuthorized'] as bool? ?? false,
|
||||
accessibilityConnected: value['accessibilityConnected'] as bool? ?? false,
|
||||
accessibilityAuthorized: accessibilityAuthorized,
|
||||
accessibilityConnected: accessibilityConnected,
|
||||
accessibilityConnectionState: AccessibilityConnectionState.parse(
|
||||
value['accessibilityConnectionState'],
|
||||
authorized: accessibilityAuthorized,
|
||||
connected: accessibilityConnected,
|
||||
),
|
||||
accessibilityLastConnectedAt: epochDate('accessibilityLastConnectedAt'),
|
||||
accessibilityLastDisconnectedAt: epochDate(
|
||||
'accessibilityLastDisconnectedAt',
|
||||
),
|
||||
recognitionProcessStartedAt: epochDate('recognitionProcessStartedAt'),
|
||||
keepAliveExpected: value['keepAliveExpected'] as bool? ?? false,
|
||||
keepAliveRunning: value['keepAliveRunning'] as bool? ?? false,
|
||||
keepAliveError: value['keepAliveError']?.toString(),
|
||||
recentsProtectionExpected:
|
||||
value['recentsProtectionExpected'] as bool? ?? false,
|
||||
recentsProtectionActive:
|
||||
value['recentsProtectionActive'] as bool? ?? false,
|
||||
lastRecognitionExitAt: epochDate('lastRecognitionExitAt'),
|
||||
lastRecognitionExitReason: value['lastRecognitionExitReason']?.toString(),
|
||||
taskCleanerRecoveryNeeded:
|
||||
value['taskCleanerRecoveryNeeded'] as bool? ?? false,
|
||||
notificationAuthorized: value['notificationAuthorized'] as bool? ?? false,
|
||||
notificationConnected: value['notificationConnected'] as bool? ?? false,
|
||||
postNotificationsGranted:
|
||||
@@ -130,6 +210,21 @@ class RecognitionStatus {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool get accessibilityNeedsRecovery =>
|
||||
accessibilityAuthorized &&
|
||||
!accessibilityConnected &&
|
||||
accessibilityConnectionState == AccessibilityConnectionState.disconnected;
|
||||
|
||||
bool get keepAliveNeedsRecovery => keepAliveExpected && !keepAliveRunning;
|
||||
|
||||
String get accessibilityConnectionLabel =>
|
||||
switch (accessibilityConnectionState) {
|
||||
AccessibilityConnectionState.unauthorized => '未授权',
|
||||
AccessibilityConnectionState.reconnecting => '正在重连',
|
||||
AccessibilityConnectionState.connected => '已连接',
|
||||
AccessibilityConnectionState.disconnected => '服务未连接',
|
||||
};
|
||||
}
|
||||
|
||||
class RecognitionCandidate {
|
||||
@@ -254,6 +349,7 @@ class SpeechEvent {
|
||||
/// Android native capabilities for screenshots, AI progress and speech.
|
||||
class ScreenshotChannel {
|
||||
static const _channel = MethodChannel('com.miaoji/screenshot');
|
||||
static Future<RecognitionStatus>? _activeAccessibilityConnectionWait;
|
||||
static void Function(String path)? _screenshotReady;
|
||||
static void Function(String error)? _screenshotError;
|
||||
static void Function(SpeechEvent event)? _speechEvent;
|
||||
@@ -403,6 +499,60 @@ class ScreenshotChannel {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<RecognitionStatus> waitForAccessibilityConnection({
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
Duration interval = const Duration(milliseconds: 500),
|
||||
}) {
|
||||
final active = _activeAccessibilityConnectionWait;
|
||||
if (active != null) return active;
|
||||
final operation = _waitForAccessibilityConnection(
|
||||
timeout: timeout,
|
||||
interval: interval,
|
||||
);
|
||||
_activeAccessibilityConnectionWait = operation;
|
||||
return operation.whenComplete(() {
|
||||
if (identical(_activeAccessibilityConnectionWait, operation)) {
|
||||
_activeAccessibilityConnectionWait = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static Future<RecognitionStatus> _waitForAccessibilityConnection({
|
||||
required Duration timeout,
|
||||
required Duration interval,
|
||||
}) async {
|
||||
var status = await recognitionStatus();
|
||||
final recognitionEnabled =
|
||||
status.accessibilityEvents || status.aiScreenshot;
|
||||
if (!recognitionEnabled ||
|
||||
!status.accessibilityAuthorized ||
|
||||
status.accessibilityConnected ||
|
||||
status.taskCleanerRecoveryNeeded) {
|
||||
return status;
|
||||
}
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
await Future<void>.delayed(interval);
|
||||
status = await recognitionStatus();
|
||||
if (!status.accessibilityAuthorized ||
|
||||
status.accessibilityConnected ||
|
||||
status.taskCleanerRecoveryNeeded ||
|
||||
!(status.accessibilityEvents || status.aiScreenshot)) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
static Future<bool> ensureRecognitionKeepAlive() async {
|
||||
try {
|
||||
return await _channel.invokeMethod<bool>('ensureRecognitionKeepAlive') ??
|
||||
false;
|
||||
} on MissingPluginException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> clearRecognitionDiagnostic() async {
|
||||
try {
|
||||
return await _channel.invokeMethod<bool>('clearRecognitionDiagnostic') ??
|
||||
|
||||
@@ -6,12 +6,13 @@ import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||
|
||||
enum AiAccessState { guest, reauthenticate, cloudDisabled }
|
||||
enum AiAccessState { guest, reauthenticate, aiDisabled, cloudDisabled }
|
||||
|
||||
AiAccessState? currentAiAccessState() {
|
||||
final session = SessionStore.instance;
|
||||
if (session.isGuest) return AiAccessState.guest;
|
||||
if (session.needsReauth) return AiAccessState.reauthenticate;
|
||||
if (!session.aiEnabled) return AiAccessState.aiDisabled;
|
||||
if (!session.cloudSyncEnabled) return AiAccessState.cloudDisabled;
|
||||
return null;
|
||||
}
|
||||
@@ -32,23 +33,27 @@ class _AiAccessGateState extends State<AiAccessGate> {
|
||||
String get _title => switch (widget.state) {
|
||||
AiAccessState.guest => '登录后使用 AI 助手',
|
||||
AiAccessState.reauthenticate => '登录状态已过期',
|
||||
AiAccessState.aiDisabled => 'AI 功能已关闭',
|
||||
AiAccessState.cloudDisabled => 'AI 功能需要云连接',
|
||||
};
|
||||
|
||||
String get _message => switch (widget.state) {
|
||||
AiAccessState.guest => '游客账单会继续安全保存在本机。登录后即可使用 AI 聊天、语音解析和图片识别。',
|
||||
AiAccessState.reauthenticate => '本地记账不受影响。重新登录后可以继续使用 AI 和云同步。',
|
||||
AiAccessState.aiDisabled => '当前账号暂未开通 AI,手动记账和统计不受影响。',
|
||||
AiAccessState.cloudDisabled => '当前账号仅使用本地数据。开启云同步后才能发送 AI 消息。',
|
||||
};
|
||||
|
||||
String get _actionLabel => switch (widget.state) {
|
||||
String? get _actionLabel => switch (widget.state) {
|
||||
AiAccessState.guest => '登录后使用',
|
||||
AiAccessState.reauthenticate => '重新登录',
|
||||
AiAccessState.aiDisabled => null,
|
||||
AiAccessState.cloudDisabled => '开启云同步',
|
||||
};
|
||||
|
||||
Future<void> _act() async {
|
||||
if (_busy) return;
|
||||
if (widget.state == AiAccessState.aiDisabled) return;
|
||||
if (widget.onAction != null) {
|
||||
await widget.onAction!();
|
||||
return;
|
||||
@@ -115,15 +120,17 @@ class _AiAccessGateState extends State<AiAccessGate> {
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 22),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: JzActionButton(
|
||||
label: _actionLabel,
|
||||
loading: _busy,
|
||||
onPressed: _busy ? null : _act,
|
||||
if (_actionLabel case final label?) ...[
|
||||
SizedBox(height: 22),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: JzActionButton(
|
||||
label: label,
|
||||
loading: _busy,
|
||||
onPressed: _busy ? null : _act,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (widget.state == AiAccessState.guest) ...[
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||
|
||||
class BackendStatusIcon extends StatelessWidget {
|
||||
final Future<void> Function() onRetry;
|
||||
|
||||
const BackendStatusIcon({super.key, required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<BackendAvailability>(
|
||||
valueListenable: ApiClient.availability,
|
||||
builder: (context, availability, _) {
|
||||
if (availability != BackendAvailability.offline) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return IconButton(
|
||||
tooltip: '当前显示本地数据,点击重试',
|
||||
onPressed: onRetry,
|
||||
icon: AppIcons.icon(
|
||||
AppIcons.offline,
|
||||
size: 18,
|
||||
color: AppTheme.orange,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:miaoji_zhang/shared/api/backend_identity.dart';
|
||||
import 'package:miaoji_zhang/shared/api/business_api.dart';
|
||||
import 'package:miaoji_zhang/shared/api/sse_frame_accumulator.dart';
|
||||
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/screenshot_channel.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
|
||||
|
||||
void main() {
|
||||
@@ -176,6 +177,55 @@ void main() {
|
||||
expect(report.balance, 2400);
|
||||
});
|
||||
|
||||
test('无障碍连接状态区分重连、正常和异常', () {
|
||||
final reconnecting = RecognitionStatus.fromMap({
|
||||
'accessibilityAuthorized': true,
|
||||
'accessibilityConnected': false,
|
||||
'accessibilityConnectionState': 'reconnecting',
|
||||
'accessibilityLastConnectedAt': 1724472000000,
|
||||
'keepAliveExpected': true,
|
||||
'keepAliveRunning': false,
|
||||
'keepAliveError': '系统限制了后台保护启动',
|
||||
'recentsProtectionExpected': true,
|
||||
'recentsProtectionActive': true,
|
||||
'lastRecognitionExitAt': 1788077518000,
|
||||
'lastRecognitionExitReason': 'low_memory: single-cleaner',
|
||||
'taskCleanerRecoveryNeeded': true,
|
||||
'settings': jsonEncode({'accessibilityEvents': true}),
|
||||
});
|
||||
final connected = RecognitionStatus.fromMap({
|
||||
'accessibilityAuthorized': true,
|
||||
'accessibilityConnected': true,
|
||||
'accessibilityConnectionState': 'connected',
|
||||
'settings': '{}',
|
||||
});
|
||||
final disconnected = RecognitionStatus.fromMap({
|
||||
'accessibilityAuthorized': true,
|
||||
'accessibilityConnected': false,
|
||||
'accessibilityConnectionState': 'disconnected',
|
||||
'settings': '{}',
|
||||
});
|
||||
|
||||
expect(
|
||||
reconnecting.accessibilityConnectionState,
|
||||
AccessibilityConnectionState.reconnecting,
|
||||
);
|
||||
expect(reconnecting.accessibilityConnectionLabel, '正在重连');
|
||||
expect(reconnecting.accessibilityLastConnectedAt, isNotNull);
|
||||
expect(reconnecting.keepAliveExpected, isTrue);
|
||||
expect(reconnecting.keepAliveRunning, isFalse);
|
||||
expect(reconnecting.keepAliveNeedsRecovery, isTrue);
|
||||
expect(reconnecting.keepAliveError, '系统限制了后台保护启动');
|
||||
expect(reconnecting.recentsProtectionExpected, isTrue);
|
||||
expect(reconnecting.recentsProtectionActive, isTrue);
|
||||
expect(reconnecting.lastRecognitionExitAt, isNotNull);
|
||||
expect(reconnecting.lastRecognitionExitReason, contains('single-cleaner'));
|
||||
expect(reconnecting.taskCleanerRecoveryNeeded, isTrue);
|
||||
expect(connected.accessibilityConnectionLabel, '已连接');
|
||||
expect(disconnected.accessibilityConnectionLabel, '服务未连接');
|
||||
expect(disconnected.accessibilityNeedsRecovery, isTrue);
|
||||
});
|
||||
|
||||
test('分类图标目录至少 32 个且键值不重复', () {
|
||||
final keys = AppIcons.categoryCatalog.map((item) => item.key).toList();
|
||||
expect(keys.length, greaterThanOrEqualTo(32));
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/api/auth_api.dart';
|
||||
import 'package:miaoji_zhang/shared/services/local_database.dart';
|
||||
import 'package:miaoji_zhang/shared/widgets/backend_status_icon.dart';
|
||||
|
||||
void main() {
|
||||
tearDown(() {
|
||||
ApiClient.availability.value = BackendAvailability.unknown;
|
||||
});
|
||||
|
||||
testWidgets('离线状态只显示可点击且可访问的云朵重试图标', (tester) async {
|
||||
var retries = 0;
|
||||
ApiClient.availability.value = BackendAvailability.offline;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
appBar: AppBar(
|
||||
actions: [
|
||||
BackendStatusIcon(
|
||||
onRetry: () async {
|
||||
retries++;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final retry = find.byTooltip('当前显示本地数据,点击重试');
|
||||
expect(retry, findsOneWidget);
|
||||
await tester.tap(retry);
|
||||
await tester.pump();
|
||||
expect(retries, 1);
|
||||
|
||||
ApiClient.availability.value = BackendAvailability.online;
|
||||
await tester.pump();
|
||||
expect(retry, findsNothing);
|
||||
});
|
||||
|
||||
test('连接、发送和接收失败都会被识别为后端不可达', () {
|
||||
DioException failure(DioExceptionType type) => DioException(
|
||||
requestOptions: RequestOptions(path: '/test'),
|
||||
type: type,
|
||||
);
|
||||
|
||||
expect(
|
||||
isConnectivityError(failure(DioExceptionType.connectionError)),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isConnectivityError(failure(DioExceptionType.connectionTimeout)),
|
||||
isTrue,
|
||||
);
|
||||
expect(isConnectivityError(failure(DioExceptionType.sendTimeout)), isTrue);
|
||||
expect(
|
||||
isConnectivityError(failure(DioExceptionType.receiveTimeout)),
|
||||
isTrue,
|
||||
);
|
||||
expect(isConnectivityError(failure(DioExceptionType.badResponse)), isFalse);
|
||||
});
|
||||
|
||||
test('AI 伙伴缓存 DTO 可无损往返', () {
|
||||
final source = {
|
||||
'avatarKey': 'cat',
|
||||
'personaKey': 'gentle',
|
||||
'customName': '小记',
|
||||
'roastLevel': 20,
|
||||
'stickerFrequency': 40,
|
||||
'proactiveLevel': 60,
|
||||
};
|
||||
final companion = AiCompanion.fromJson(source);
|
||||
|
||||
expect(companion.toJson(), source);
|
||||
});
|
||||
|
||||
test('账号分类缓存为空时会补齐支出和收入兜底并允许本地记账', () {
|
||||
final database = LocalDatabase.inMemoryForTesting(seedDefaults: false);
|
||||
addTearDown(database.close);
|
||||
|
||||
database.ensureDefaultCategories();
|
||||
final expense = database.categories('expense');
|
||||
final income = database.categories('income');
|
||||
|
||||
expect(expense, hasLength(12));
|
||||
expect(income, hasLength(7));
|
||||
expect(expense.first['name'], '餐饮');
|
||||
expect(income.first['name'], '工资');
|
||||
expect(income.first['id'], 13);
|
||||
|
||||
final transaction = database.createTransaction({
|
||||
'ledgerId': 1,
|
||||
'categoryId': expense.first['id'],
|
||||
'type': 'expense',
|
||||
'amount': 18.5,
|
||||
'occurredAt': DateTime.utc(2026, 8, 21).toIso8601String(),
|
||||
});
|
||||
expect(transaction['amount'], 18.5);
|
||||
expect(transaction['categoryName'], '餐饮');
|
||||
});
|
||||
|
||||
test('七个页面保持本地首屏、后台刷新、竞态保护和统一离线入口', () {
|
||||
final contracts = <String, List<String>>{
|
||||
'lib/features/home/pages/home_page.dart': [
|
||||
'monthLocal',
|
||||
'monthRemote',
|
||||
'BudgetApi.getLocal',
|
||||
'BudgetApi.getRemote',
|
||||
'refreshRemote',
|
||||
],
|
||||
'lib/features/add/add_page.dart': [
|
||||
"categoriesLocal('expense')",
|
||||
"categoriesRemote('expense')",
|
||||
"categoriesRemote('income')",
|
||||
'localFirst: true',
|
||||
],
|
||||
'lib/features/stats/stats_page.dart': [
|
||||
'periodStatsLocal',
|
||||
'periodStatsRemote',
|
||||
],
|
||||
'lib/features/stats/report_page.dart': [
|
||||
'ReportApi.local',
|
||||
'ReportApi.remote',
|
||||
],
|
||||
'lib/features/settings/category_manage_page.dart': [
|
||||
'categoriesLocal',
|
||||
'categoriesRemote',
|
||||
'localFirst: true',
|
||||
],
|
||||
'lib/features/settings/recycle_bin_page.dart': [
|
||||
'recycleBinLocal',
|
||||
'recycleBinRemote',
|
||||
],
|
||||
'lib/features/settings/companion_page.dart': [
|
||||
'cachedCompanion',
|
||||
'cachedAvatars',
|
||||
'cachedPersonas',
|
||||
'forceRemote: true',
|
||||
],
|
||||
};
|
||||
|
||||
for (final entry in contracts.entries) {
|
||||
final source = File(entry.key).readAsStringSync();
|
||||
expect(source, contains('_loadRevision'), reason: entry.key);
|
||||
expect(source, contains('BackendStatusIcon'), reason: entry.key);
|
||||
for (final token in entry.value) {
|
||||
expect(source, contains(token), reason: '${entry.key}: $token');
|
||||
}
|
||||
}
|
||||
|
||||
final companion = File(
|
||||
'lib/features/settings/companion_page.dart',
|
||||
).readAsStringSync();
|
||||
expect(companion, isNot(contains('if (!_loaded)')));
|
||||
expect(companion, contains('BackendAvailability.online'));
|
||||
});
|
||||
|
||||
test('AI 缓存按后端和账号隔离,回收站破坏性操作只允许在线执行', () {
|
||||
final auth = File('lib/shared/api/auth_api.dart').readAsStringSync();
|
||||
final config = File('lib/shared/api/config_api.dart').readAsStringSync();
|
||||
final recycle = File(
|
||||
'lib/features/settings/recycle_bin_page.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(auth, contains("'ai_companion_\${BackendIdentity.scope}_\$userId'"));
|
||||
expect(config, contains("'public_avatars_\${BackendIdentity.scope}'"));
|
||||
expect(config, contains("'public_personas_\${BackendIdentity.scope}'"));
|
||||
expect(recycle, contains('availability != BackendAvailability.online'));
|
||||
expect(recycle, contains('if (online)'));
|
||||
});
|
||||
}
|
||||
@@ -153,9 +153,21 @@ void main() {
|
||||
);
|
||||
expect(
|
||||
RecognitionDiagnosticDisplay.from(
|
||||
diagnostic('rejected', 'payment_input_page'),
|
||||
diagnostic('waiting', 'payment_input_page'),
|
||||
).summaryLabel,
|
||||
'规则拒绝',
|
||||
'等待结果页',
|
||||
);
|
||||
expect(
|
||||
RecognitionDiagnosticDisplay.from(
|
||||
diagnostic('waiting', 'payment_input_page'),
|
||||
).reasonLabel,
|
||||
'当前仍是付款输入或确认页面,等待结果页,不会提前入账',
|
||||
);
|
||||
expect(
|
||||
RecognitionDiagnosticDisplay.from(
|
||||
diagnostic('rejected', 'result_page_timeout'),
|
||||
).reasonLabel,
|
||||
'90 秒内未等到明确结果页,本次流程已停止追踪',
|
||||
);
|
||||
expect(
|
||||
RecognitionDiagnosticDisplay.from(
|
||||
@@ -163,5 +175,17 @@ void main() {
|
||||
).summaryLabel,
|
||||
'已合并',
|
||||
);
|
||||
expect(
|
||||
RecognitionDiagnosticDisplay.from(
|
||||
diagnostic('confirm', 'expected_amount_fallback'),
|
||||
).reasonLabel,
|
||||
'结果页未读到金额,已使用本次付款流程确认的金额',
|
||||
);
|
||||
expect(
|
||||
RecognitionDiagnosticDisplay.from(
|
||||
diagnostic('confirm', 'result_amount_conflict'),
|
||||
).reasonLabel,
|
||||
'结果页金额与本次付款金额不一致,请确认后入账',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,6 +68,9 @@ void main() {
|
||||
final companion = File(
|
||||
'lib/features/settings/companion_page.dart',
|
||||
).readAsStringSync();
|
||||
final onboarding = File(
|
||||
'lib/features/onboarding/pages/onboarding_page.dart',
|
||||
).readAsStringSync();
|
||||
final me = File('lib/features/settings/me_page.dart').readAsStringSync();
|
||||
final report = File(
|
||||
'lib/features/stats/report_page.dart',
|
||||
@@ -80,10 +83,27 @@ void main() {
|
||||
companion,
|
||||
isNot(contains('context.jz.aiBackground : Colors.white')),
|
||||
);
|
||||
expect(onboarding, contains(': context.jz.card'));
|
||||
expect(
|
||||
onboarding,
|
||||
isNot(contains('context.jz.aiBackground : Colors.white')),
|
||||
);
|
||||
expect(me, isNot(contains('context.jz.primaryBackground : Colors.white')));
|
||||
expect(report, isNot(contains('selected ? Colors.white')));
|
||||
});
|
||||
|
||||
test('首次引导只提交服务端实际返回的 AI 伙伴配置', () {
|
||||
final onboarding = File(
|
||||
'lib/features/onboarding/pages/onboarding_page.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(onboarding, contains('_hasValidCatalogSelection'));
|
||||
expect(onboarding, contains('AI 伙伴配置暂不可用'));
|
||||
expect(onboarding, contains('重新加载'));
|
||||
expect(onboarding, isNot(contains('_fallbackAvatars')));
|
||||
expect(onboarding, isNot(contains('_fallbackPersonas')));
|
||||
});
|
||||
|
||||
test('聊天附件只保留拍照和相册导入', () {
|
||||
final source = File('lib/features/chat/chat_page.dart').readAsStringSync();
|
||||
|
||||
@@ -94,6 +114,49 @@ void main() {
|
||||
expect(source, isNot(contains('ScreenshotChannel.capture')));
|
||||
});
|
||||
|
||||
test('底部导航固定保留 AI 入口且关闭状态在聊天二级页处理', () {
|
||||
final shell = File(
|
||||
'lib/features/home/pages/main_shell.dart',
|
||||
).readAsStringSync();
|
||||
final router = File('lib/app/app.dart').readAsStringSync();
|
||||
final gate = File(
|
||||
'lib/shared/widgets/ai_access_gate.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(shell, isNot(contains('showAiEntry')));
|
||||
expect(shell, contains('_tab(companion.name, AppIcons.chat, 2)'));
|
||||
expect(router, isNot(contains("state.matchedLocation == '/chat'")));
|
||||
expect(gate, contains('AiAccessState.aiDisabled'));
|
||||
expect(gate, contains('AI 功能已关闭'));
|
||||
expect(gate, contains('手动记账和统计不受影响'));
|
||||
});
|
||||
|
||||
test('报告入口归入统计并继承当前周期', () {
|
||||
final stats = File('lib/features/stats/stats_page.dart').readAsStringSync();
|
||||
final report = File(
|
||||
'lib/features/stats/report_page.dart',
|
||||
).readAsStringSync();
|
||||
final me = File('lib/features/settings/me_page.dart').readAsStringSync();
|
||||
|
||||
expect(me, isNot(contains("context.push('/report')")));
|
||||
expect(stats, contains("path: '/report'"));
|
||||
expect(stats, contains("'period': _period"));
|
||||
expect(stats, contains("'date': _anchor.toIso8601String()"));
|
||||
expect(stats, contains('查看完整报告'));
|
||||
expect(report, contains('widget.initialPeriod'));
|
||||
expect(report, contains('widget.initialAnchor'));
|
||||
});
|
||||
|
||||
test('聊天输入为空显示附件加号,有文本时显示发送按钮', () {
|
||||
final chat = File('lib/features/chat/chat_page.dart').readAsStringSync();
|
||||
|
||||
expect(chat, contains("ValueKey('add-attachment')"));
|
||||
expect(chat, contains("ValueKey('send-message')"));
|
||||
expect(chat, contains('child: hasText'));
|
||||
expect(chat, contains("tooltip: '添加附件'"));
|
||||
expect(chat, isNot(contains("'附件'")));
|
||||
});
|
||||
|
||||
test('表情包具备离线缓存、内置兜底和展开刷新', () {
|
||||
final api = File('lib/shared/api/business_api.dart').readAsStringSync();
|
||||
final chat = File('lib/features/chat/chat_page.dart').readAsStringSync();
|
||||
@@ -216,4 +279,68 @@ void main() {
|
||||
expect(source, contains('setRecognitionToggle(key, false)'));
|
||||
expect(source, contains('_BackgroundKeepAliveCard'));
|
||||
});
|
||||
|
||||
test('长期识别组件使用独立保活进程并支持非阻塞重连', () {
|
||||
final manifest = File(
|
||||
'android/app/src/main/AndroidManifest.xml',
|
||||
).readAsStringSync();
|
||||
final channel = File(
|
||||
'lib/shared/services/screenshot_channel.dart',
|
||||
).readAsStringSync();
|
||||
final activity = File(
|
||||
'android/app/src/main/kotlin/com/nx/miaoji/MainActivity.kt',
|
||||
).readAsStringSync();
|
||||
final settingsPage = File(
|
||||
'lib/features/settings/screenshot_settings_page.dart',
|
||||
).readAsStringSync();
|
||||
final keepAlive = File(
|
||||
'android/app/src/main/kotlin/com/nx/miaoji/RecognitionKeepAliveService.kt',
|
||||
).readAsStringSync();
|
||||
|
||||
for (final component in [
|
||||
'ScreenshotTileService',
|
||||
'PaymentNotificationListenerService',
|
||||
'ScreenshotAccessibilityService',
|
||||
]) {
|
||||
final declaration = RegExp(
|
||||
'<service\\s+android:name="\\.$component"[\\s\\S]*?</service>',
|
||||
).firstMatch(manifest)?.group(0);
|
||||
expect(declaration, isNotNull);
|
||||
expect(declaration, contains('android:stopWithTask="false"'));
|
||||
expect(declaration, contains('android:process=":recognition"'));
|
||||
}
|
||||
final provider = RegExp(
|
||||
'<provider\\s+android:name="\\.RecognitionBridgeProvider"[\\s\\S]*?/>',
|
||||
).firstMatch(manifest)?.group(0);
|
||||
expect(provider, isNotNull);
|
||||
expect(provider, contains('android:process=":recognition"'));
|
||||
expect(manifest, contains('android:name=".OneShotProjectionService"'));
|
||||
final projection = RegExp(
|
||||
'<service\\s+android:name="\\.OneShotProjectionService"[\\s\\S]*?/>',
|
||||
).firstMatch(manifest)?.group(0);
|
||||
expect(projection, isNotNull);
|
||||
expect(projection, contains('android:process=":projection"'));
|
||||
expect(manifest, contains('android:name=".RecognitionKeepAliveService"'));
|
||||
expect(manifest, contains('android:foregroundServiceType="specialUse"'));
|
||||
expect(manifest, contains('FOREGROUND_SERVICE_SPECIAL_USE'));
|
||||
expect(manifest, contains('PROPERTY_SPECIAL_USE_FGS_SUBTYPE'));
|
||||
expect(keepAlive, contains('START_STICKY'));
|
||||
expect(keepAlive, contains('智能识别运行中'));
|
||||
expect(channel, contains('waitForAccessibilityConnection'));
|
||||
expect(channel, contains('_activeAccessibilityConnectionWait'));
|
||||
expect(channel, contains('ensureRecognitionKeepAlive'));
|
||||
expect(channel, contains('AccessibilityConnectionState.reconnecting'));
|
||||
expect(activity, contains('"reconnecting"'));
|
||||
expect(activity, contains('"disconnected"'));
|
||||
expect(activity, contains('setExcludeFromRecents'));
|
||||
expect(activity, contains('getHistoricalProcessExitReasons'));
|
||||
expect(activity, contains('single-cleaner'));
|
||||
expect(settingsPage, contains('_check();'));
|
||||
expect(settingsPage, isNot(contains('_check(waitForConnection: true)')));
|
||||
expect(settingsPage, contains('系统仍显示已授权,但 OriginOS 未重新绑定服务'));
|
||||
expect(settingsPage, contains('启动后台保护'));
|
||||
expect(settingsPage, contains('OriginOS 清理了识别进程'));
|
||||
expect(settingsPage, contains('记之已从最近任务隐藏'));
|
||||
expect(channel, contains('taskCleanerRecoveryNeeded'));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user