Compare commits
1 Commits
dev
..
403776cc1c
| Author | SHA1 | Date | |
|---|---|---|---|
| 403776cc1c |
@@ -40,8 +40,6 @@ jobs:
|
|||||||
web:
|
web:
|
||||||
name: Shared client and Web
|
name: Shared client and Web
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
@@ -106,8 +104,6 @@ jobs:
|
|||||||
desktop:
|
desktop:
|
||||||
name: macOS Desktop
|
name: macOS Desktop
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
env:
|
|
||||||
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|||||||
@@ -19,35 +19,8 @@ concurrency:
|
|||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-policy:
|
|
||||||
name: Resolve desktop release signing policy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
platform_signing_mode: ${{ steps.policy.outputs.platform_signing_mode }}
|
|
||||||
macos_signing: ${{ steps.policy.outputs.macos_signing }}
|
|
||||||
windows_signing: ${{ steps.policy.outputs.windows_signing }}
|
|
||||||
artifact_label: ${{ steps.policy.outputs.artifact_label }}
|
|
||||||
warning_required: ${{ steps.policy.outputs.warning_required }}
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: frontend
|
|
||||||
steps:
|
|
||||||
- name: Checkout release source
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "22.13"
|
|
||||||
|
|
||||||
- name: Resolve version-scoped signing policy
|
|
||||||
id: policy
|
|
||||||
shell: bash
|
|
||||||
run: npm run desktop:release-policy:check -- --require-tag --github-output "${GITHUB_OUTPUT}"
|
|
||||||
|
|
||||||
macos-release-candidate:
|
macos-release-candidate:
|
||||||
name: macOS release candidate
|
name: Signed macOS release candidate
|
||||||
needs: release-policy
|
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -55,13 +28,8 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
VITE_BUILD_CHANNEL: release
|
VITE_BUILD_CHANNEL: release
|
||||||
VITE_BUILD_COMMIT: ${{ github.sha }}
|
VITE_BUILD_COMMIT: ${{ github.sha }}
|
||||||
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
|
|
||||||
RELEASE_BUILD: "true"
|
RELEASE_BUILD: "true"
|
||||||
DESKTOP_RELEASE_PLATFORM: macos
|
REQUIRE_DESKTOP_SIGNING: "true"
|
||||||
DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }}
|
|
||||||
REQUIRE_UPDATER_SIGNING: "true"
|
|
||||||
REQUIRE_DESKTOP_SIGNING: ${{ needs.release-policy.outputs.platform_signing_mode == 'signed' && 'true' || 'false' }}
|
|
||||||
ALLOW_UNSIGNED_PLATFORM_RELEASE: ${{ needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' && 'true' || 'false' }}
|
|
||||||
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
|
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
@@ -86,7 +54,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
|
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
|
||||||
echo "Desktop release candidates must run from a vX.Y.Z tag."
|
echo "Signed desktop release candidates must run from a vX.Y.Z tag."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
expected_tag="v$(node -p "require('./package.json').version")"
|
expected_tag="v$(node -p "require('./package.json').version")"
|
||||||
@@ -109,7 +77,7 @@ jobs:
|
|||||||
- name: Check release build metadata and signing environment
|
- name: Check release build metadata and signing environment
|
||||||
run: npm run release:env:check
|
run: npm run release:env:check
|
||||||
|
|
||||||
- name: Check desktop release readiness
|
- name: Check signed desktop release readiness
|
||||||
run: npm run desktop:release-readiness:check
|
run: npm run desktop:release-readiness:check
|
||||||
|
|
||||||
- name: Check synchronized client version
|
- name: Check synchronized client version
|
||||||
@@ -133,14 +101,9 @@ jobs:
|
|||||||
- name: Build Web artifact
|
- name: Build Web artifact
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Build Apple-signed and notarized Universal macOS artifacts
|
- name: Build signed and notarized Universal macOS artifacts
|
||||||
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
|
|
||||||
run: npm run desktop:build:macos-release -- --ci
|
run: npm run desktop:build:macos-release -- --ci
|
||||||
|
|
||||||
- name: Build ad-hoc Universal macOS artifacts
|
|
||||||
if: needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception'
|
|
||||||
run: npm run desktop:build:macos-unsigned-release -- --ci
|
|
||||||
|
|
||||||
- name: Verify and stage macOS artifacts
|
- name: Verify and stage macOS artifacts
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -148,46 +111,28 @@ jobs:
|
|||||||
artifact="$(find src-tauri/target -path '*/release/bundle/macos/*.app.tar.gz' -print -quit)"
|
artifact="$(find src-tauri/target -path '*/release/bundle/macos/*.app.tar.gz' -print -quit)"
|
||||||
dmg="$(find src-tauri/target -path '*/release/bundle/dmg/*.dmg' -print -quit)"
|
dmg="$(find src-tauri/target -path '*/release/bundle/dmg/*.dmg' -print -quit)"
|
||||||
if [[ -z "${app}" || -z "${artifact}" || -z "${dmg}" || ! -s "${artifact}.sig" ]]; then
|
if [[ -z "${app}" || -z "${artifact}" || -z "${dmg}" || ! -s "${artifact}.sig" ]]; then
|
||||||
echo "macOS app, updater artifact/signature, and DMG are all required."
|
echo "Signed macOS app, updater artifact/signature, and DMG are all required."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
codesign --verify --deep --strict --verbose=2 "${app}"
|
codesign --verify --deep --strict --verbose=2 "${app}"
|
||||||
if [[ "${DESKTOP_PLATFORM_SIGNING_MODE}" == "signed" ]]; then
|
spctl --assess --type execute --verbose=2 "${app}"
|
||||||
spctl --assess --type execute --verbose=2 "${app}"
|
xcrun stapler validate "${app}"
|
||||||
xcrun stapler validate "${app}"
|
xcrun stapler validate "${dmg}"
|
||||||
xcrun stapler validate "${dmg}"
|
|
||||||
else
|
|
||||||
signature_info="$(codesign -dv --verbose=4 "${app}" 2>&1)"
|
|
||||||
if ! grep -q "Signature=adhoc" <<<"${signature_info}"; then
|
|
||||||
echo "The macOS unsigned-platform exception must produce an ad-hoc signed app."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
stage="src-tauri/target/desktop-release-macos"
|
stage="src-tauri/target/desktop-release-macos"
|
||||||
mkdir -p "${stage}"
|
mkdir -p "${stage}"
|
||||||
if [[ "${DESKTOP_PLATFORM_SIGNING_MODE}" == "unsigned-exception" ]]; then
|
cp "${artifact}" "${artifact}.sig" "${dmg}" "${stage}/"
|
||||||
artifact_name="$(basename "${artifact}" .app.tar.gz)_UNSIGNED.app.tar.gz"
|
|
||||||
dmg_name="$(basename "${dmg}" .dmg)_UNSIGNED.dmg"
|
|
||||||
cp "${artifact}" "${stage}/${artifact_name}"
|
|
||||||
cp "${artifact}.sig" "${stage}/${artifact_name}.sig"
|
|
||||||
cp "${dmg}" "${stage}/${dmg_name}"
|
|
||||||
cp desktop-release-unsigned-warning.txt "${stage}/UNSIGNED-PLATFORM-RELEASE.txt"
|
|
||||||
else
|
|
||||||
cp "${artifact}" "${artifact}.sig" "${dmg}" "${stage}/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload macOS candidate artifacts
|
- name: Upload signed macOS candidate artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
|
name: ctms-desktop-macos-${{ github.ref_name }}
|
||||||
path: frontend/src-tauri/target/desktop-release-macos/*
|
path: frontend/src-tauri/target/desktop-release-macos/*
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
windows-release-candidate:
|
windows-release-candidate:
|
||||||
name: Windows release candidate
|
name: Signed Windows release candidate
|
||||||
needs: release-policy
|
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -195,13 +140,8 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
VITE_BUILD_CHANNEL: release
|
VITE_BUILD_CHANNEL: release
|
||||||
VITE_BUILD_COMMIT: ${{ github.sha }}
|
VITE_BUILD_COMMIT: ${{ github.sha }}
|
||||||
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
|
|
||||||
RELEASE_BUILD: "true"
|
RELEASE_BUILD: "true"
|
||||||
DESKTOP_RELEASE_PLATFORM: windows
|
REQUIRE_WINDOWS_SIGNING: "true"
|
||||||
DESKTOP_PLATFORM_SIGNING_MODE: ${{ needs.release-policy.outputs.platform_signing_mode }}
|
|
||||||
REQUIRE_UPDATER_SIGNING: "true"
|
|
||||||
REQUIRE_WINDOWS_SIGNING: ${{ needs.release-policy.outputs.platform_signing_mode == 'signed' && 'true' || 'false' }}
|
|
||||||
ALLOW_UNSIGNED_PLATFORM_RELEASE: ${{ needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception' && 'true' || 'false' }}
|
|
||||||
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
|
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
@@ -223,7 +163,7 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
if ($env:GITHUB_REF_TYPE -ne "tag") {
|
if ($env:GITHUB_REF_TYPE -ne "tag") {
|
||||||
throw "Desktop release candidates must run from a vX.Y.Z tag."
|
throw "Signed desktop release candidates must run from a vX.Y.Z tag."
|
||||||
}
|
}
|
||||||
$expectedTag = "v$(node -p "require('./package.json').version")"
|
$expectedTag = "v$(node -p "require('./package.json').version")"
|
||||||
if ($env:GITHUB_REF_NAME -ne $expectedTag) {
|
if ($env:GITHUB_REF_NAME -ne $expectedTag) {
|
||||||
@@ -242,7 +182,7 @@ jobs:
|
|||||||
- name: Check release build metadata and signing environment
|
- name: Check release build metadata and signing environment
|
||||||
run: npm run release:env:check
|
run: npm run release:env:check
|
||||||
|
|
||||||
- name: Check desktop release readiness
|
- name: Check signed desktop release readiness
|
||||||
run: npm run desktop:release-readiness:check
|
run: npm run desktop:release-readiness:check
|
||||||
|
|
||||||
- name: Check synchronized client version
|
- name: Check synchronized client version
|
||||||
@@ -267,7 +207,6 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Import Windows code-signing certificate
|
- name: Import Windows code-signing certificate
|
||||||
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$pfxPath = Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx"
|
$pfxPath = Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx"
|
||||||
@@ -294,7 +233,6 @@ jobs:
|
|||||||
Remove-Item $pfxPath -Force
|
Remove-Item $pfxPath -Force
|
||||||
|
|
||||||
- name: Write signed Windows Tauri config
|
- name: Write signed Windows Tauri config
|
||||||
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$config = @{
|
$config = @{
|
||||||
@@ -311,16 +249,10 @@ jobs:
|
|||||||
Get-Content "tauri.windows.release.conf.json"
|
Get-Content "tauri.windows.release.conf.json"
|
||||||
|
|
||||||
- name: Build signed Windows NSIS artifacts
|
- name: Build signed Windows NSIS artifacts
|
||||||
if: needs.release-policy.outputs.platform_signing_mode == 'signed'
|
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
run: npm run desktop:build:windows-release -- --config tauri.windows.release.conf.json --ci
|
run: npm run desktop:build:windows-release -- --config tauri.windows.release.conf.json --ci
|
||||||
|
|
||||||
- name: Build unsigned Windows NSIS artifacts
|
- name: Verify Authenticode and stage Windows artifacts
|
||||||
if: needs.release-policy.outputs.platform_signing_mode == 'unsigned-exception'
|
|
||||||
timeout-minutes: 30
|
|
||||||
run: npm run desktop:build:windows-release -- --ci
|
|
||||||
|
|
||||||
- name: Verify platform signing mode and stage Windows artifacts
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$bundleDir = "src-tauri/target/release/bundle/nsis"
|
$bundleDir = "src-tauri/target/release/bundle/nsis"
|
||||||
@@ -338,27 +270,14 @@ jobs:
|
|||||||
|
|
||||||
foreach ($executable in @($appExecutables + $installers)) {
|
foreach ($executable in @($appExecutables + $installers)) {
|
||||||
$signature = Get-AuthenticodeSignature -FilePath $executable.FullName
|
$signature = Get-AuthenticodeSignature -FilePath $executable.FullName
|
||||||
if ($env:DESKTOP_PLATFORM_SIGNING_MODE -eq "signed") {
|
if ($signature.Status -ne "Valid" -or -not $signature.SignerCertificate -or -not $signature.TimeStamperCertificate) {
|
||||||
if ($signature.Status -ne "Valid" -or -not $signature.SignerCertificate -or -not $signature.TimeStamperCertificate) {
|
throw "Authenticode signature or RFC 3161 timestamp is invalid for $($executable.FullName): $($signature.StatusMessage)"
|
||||||
throw "Authenticode signature or RFC 3161 timestamp is invalid for $($executable.FullName): $($signature.StatusMessage)"
|
|
||||||
}
|
|
||||||
} elseif ($signature.Status -ne "NotSigned") {
|
|
||||||
throw "The Windows unsigned-platform exception must produce an Authenticode-unsigned executable: $($executable.FullName) returned $($signature.Status)."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$stage = "src-tauri/target/desktop-release-windows"
|
$stage = "src-tauri/target/desktop-release-windows"
|
||||||
New-Item -ItemType Directory -Path $stage -Force | Out-Null
|
New-Item -ItemType Directory -Path $stage -Force | Out-Null
|
||||||
if ($env:DESKTOP_PLATFORM_SIGNING_MODE -eq "unsigned-exception") {
|
Copy-Item $installers[0].FullName, $updaters[0].FullName, $signaturePath -Destination $stage
|
||||||
$installerName = $installers[0].Name -replace '\.exe$', '_UNSIGNED.exe'
|
|
||||||
$updaterName = $updaters[0].Name -replace '\.nsis\.zip$', '_UNSIGNED.nsis.zip'
|
|
||||||
Copy-Item $installers[0].FullName -Destination (Join-Path $stage $installerName)
|
|
||||||
Copy-Item $updaters[0].FullName -Destination (Join-Path $stage $updaterName)
|
|
||||||
Copy-Item $signaturePath -Destination (Join-Path $stage "$updaterName.sig")
|
|
||||||
Copy-Item "desktop-release-unsigned-warning.txt" -Destination (Join-Path $stage "UNSIGNED-PLATFORM-RELEASE.txt")
|
|
||||||
} else {
|
|
||||||
Copy-Item $installers[0].FullName, $updaters[0].FullName, $signaturePath -Destination $stage
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Remove Windows signing certificate
|
- name: Remove Windows signing certificate
|
||||||
if: always()
|
if: always()
|
||||||
@@ -370,17 +289,16 @@ jobs:
|
|||||||
Remove-Item (Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx") -Force -ErrorAction SilentlyContinue
|
Remove-Item (Join-Path $env:RUNNER_TEMP "ctms-windows-signing.pfx") -Force -ErrorAction SilentlyContinue
|
||||||
Remove-Item "tauri.windows.release.conf.json" -Force -ErrorAction SilentlyContinue
|
Remove-Item "tauri.windows.release.conf.json" -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
- name: Upload Windows candidate artifacts
|
- name: Upload signed Windows candidate artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
|
name: ctms-desktop-windows-${{ github.ref_name }}
|
||||||
path: frontend/src-tauri/target/desktop-release-windows/*
|
path: frontend/src-tauri/target/desktop-release-windows/*
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
aggregate-release-candidate:
|
aggregate-release-candidate:
|
||||||
name: Verify combined desktop release directory
|
name: Verify combined desktop release directory
|
||||||
needs:
|
needs:
|
||||||
- release-policy
|
|
||||||
- macos-release-candidate
|
- macos-release-candidate
|
||||||
- windows-release-candidate
|
- windows-release-candidate
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -398,21 +316,18 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: "22.13"
|
node-version: "22.13"
|
||||||
|
|
||||||
- name: Download macOS candidate artifacts
|
- name: Download signed macOS candidate artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ctms-desktop-macos-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
|
name: ctms-desktop-macos-${{ github.ref_name }}
|
||||||
path: frontend/src-tauri/target/desktop-release-input/macos
|
path: frontend/src-tauri/target/desktop-release-input/macos
|
||||||
|
|
||||||
- name: Download Windows candidate artifacts
|
- name: Download signed Windows candidate artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ctms-desktop-windows-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
|
name: ctms-desktop-windows-${{ github.ref_name }}
|
||||||
path: frontend/src-tauri/target/desktop-release-input/windows
|
path: frontend/src-tauri/target/desktop-release-input/windows
|
||||||
|
|
||||||
- name: Create desktop release provenance
|
|
||||||
run: npm run desktop:release-provenance:create -- --macos-signing "${{ needs.release-policy.outputs.macos_signing }}" --windows-signing "${{ needs.release-policy.outputs.windows_signing }}" --output src-tauri/target/desktop-release-input/DESKTOP-RELEASE-PROVENANCE.json
|
|
||||||
|
|
||||||
- name: Create combined desktop update feed
|
- name: Create combined desktop update feed
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -425,32 +340,20 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
include_args=(--include "${dmgs[0]}" --include "${installers[0]}" --include src-tauri/target/desktop-release-input/DESKTOP-RELEASE-PROVENANCE.json)
|
|
||||||
notes_args=()
|
|
||||||
if [[ "${{ needs.release-policy.outputs.warning_required }}" == "true" ]]; then
|
|
||||||
mapfile -t warnings < <(find src-tauri/target/desktop-release-input/macos -name 'UNSIGNED-PLATFORM-RELEASE.txt' -type f)
|
|
||||||
if [[ ${#warnings[@]} -ne 1 ]]; then
|
|
||||||
echo "The unsigned-platform release warning is required."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
include_args+=(--include "${warnings[0]}")
|
|
||||||
notes_args+=(--notes "UNSIGNED PLATFORM RELEASE: macOS is ad-hoc signed and not notarized; Windows is not Authenticode-signed. Gatekeeper and SmartScreen warnings are expected. Verify tag, commit, updater signatures, and SHA256SUMS.txt before installation.")
|
|
||||||
fi
|
|
||||||
|
|
||||||
npm run desktop:update-feed:create -- \
|
npm run desktop:update-feed:create -- \
|
||||||
--artifact "${mac_artifacts[0]}" \
|
--artifact "${mac_artifacts[0]}" \
|
||||||
--platform-artifact "windows-x86_64=${windows_artifacts[0]}" \
|
--platform-artifact "windows-x86_64=${windows_artifacts[0]}" \
|
||||||
"${include_args[@]}" \
|
--include "${dmgs[0]}" \
|
||||||
"${notes_args[@]}" \
|
--include "${installers[0]}" \
|
||||||
--output-dir src-tauri/target/desktop-release-feed \
|
--output-dir src-tauri/target/desktop-release-feed \
|
||||||
--base-url "${DESKTOP_UPDATE_BASE_URL}"
|
--base-url "${DESKTOP_UPDATE_BASE_URL}"
|
||||||
|
|
||||||
- name: Verify combined desktop update feed
|
- name: Verify combined desktop update feed
|
||||||
run: npm run desktop:update-feed:check -- --feed src-tauri/target/desktop-release-feed/latest.json --artifacts-dir src-tauri/target/desktop-release-feed --base-url "${DESKTOP_UPDATE_BASE_URL}" --require-platform windows-x86_64 --require-provenance
|
run: npm run desktop:update-feed:check -- --feed src-tauri/target/desktop-release-feed/latest.json --artifacts-dir src-tauri/target/desktop-release-feed --base-url "${DESKTOP_UPDATE_BASE_URL}" --require-platform windows-x86_64
|
||||||
|
|
||||||
- name: Upload verified desktop release directory
|
- name: Upload verified desktop release directory
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ctms-desktop-release-${{ github.ref_name }}-${{ needs.release-policy.outputs.artifact_label }}
|
name: ctms-desktop-release-${{ github.ref_name }}
|
||||||
path: frontend/src-tauri/target/desktop-release-feed/*
|
path: frontend/src-tauri/target/desktop-release-feed/*
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ jobs:
|
|||||||
windows-internal:
|
windows-internal:
|
||||||
name: Windows NSIS internal validation
|
name: Windows NSIS internal validation
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
env:
|
|
||||||
VITE_DESKTOP_SERVER_URL: ${{ vars.VITE_DESKTOP_SERVER_URL }}
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|||||||
@@ -14,9 +14,7 @@
|
|||||||
- 未完成后端 token 校验和 `/me` 身份确认前,不得展示业务缓存;登出、切换服务器、切换用户、401/403、权限上下文变化或缓存 schema 变化时必须清理或失效相关缓存。
|
- 未完成后端 token 校验和 `/me` 身份确认前,不得展示业务缓存;登出、切换服务器、切换用户、401/403、权限上下文变化或缓存 schema 变化时必须清理或失效相关缓存。
|
||||||
- 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知、本地缓存持久化或底层存储能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs`、`npm run runtime:check` 和桌面发布检查清单是否需要更新。
|
- 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知、本地缓存持久化或底层存储能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs`、`npm run runtime:check` 和桌面发布检查清单是否需要更新。
|
||||||
- token、附件下载凭据和敏感业务信息不得写入 URL、日志、系统通知正文或明文浏览器存储。
|
- token、附件下载凭据和敏感业务信息不得写入 URL、日志、系统通知正文或明文浏览器存储。
|
||||||
- 正式桌面客户端的默认 CTMS 服务端入口必须由构建环境变量 `VITE_DESKTOP_SERVER_URL` 注入,不得在运行时代码中写死生产域名;用户仍可在桌面服务器设置中手动覆盖,手动值优先并沿用既有的切换服务器登出与缓存失效边界。该变量只表示业务服务端 origin,不得与 updater 制品前缀 `DESKTOP_UPDATE_BASE_URL` 混用。
|
- Windows x64 NSIS 已获准作为正式桌面发布目标;正式制品必须由 `.github/workflows/desktop-release-candidate.yml` 从与 macOS/Web 相同的 `vX.Y.Z` tag 和 SHA 构建,使用组织 Windows 代码签名证书、RFC 3161 时间戳和 updater 私钥,并通过 Authenticode 与 updater feed 校验。`.github/workflows/desktop-windows-internal.yml` 仍只用于分支上的无签名兼容性验证,不得生成生产 `latest.json`、updater feed 或正式发布制品。
|
||||||
- Windows x64 NSIS 已获准作为正式桌面发布目标;正式制品必须由 `.github/workflows/desktop-release-candidate.yml` 从与 macOS/Web 相同的 `vX.Y.Z` tag 和 SHA 构建。平台签名默认要求组织 Windows 代码签名证书、RFC 3161 时间戳和 Authenticode 校验;只有 `frontend/desktop-release-policy.json` 中按精确版本记录、经发布负责人批准的例外可跳过平台签名。无论是否采用平台签名例外,都必须使用 updater 私钥、校验 updater feed,并明确标记平台未签名风险。`.github/workflows/desktop-windows-internal.yml` 仍只用于分支上的无签名兼容性验证,不得生成生产 `latest.json`、updater feed 或正式发布制品。
|
|
||||||
- v0.1.0 另获准在 GitHub Actions 额度不可用时,从最终 `release` 上不可移动的 `v0.1.0` tag/SHA 在受控 macOS 主机本地构建并先行上传 macOS ad-hoc 制品;Windows 只能在额度恢复后从同一 tag/SHA 后补。面向安装用户的 GitHub Release 只保留 DMG 与只校验该安装包的 `SHA256SUMS.txt`,平台未签名风险和 Windows pending 状态写入 Release Notes。macOS updater 包及 `.sig`、完整 checksum、provenance、`UNSIGNED-PLATFORM` 与 Windows pending 证据必须保存在被 Git 忽略的私有发布目录,待 Windows `NotSigned` 制品和联合 feed 均验证通过后再发布到可匿名读取的独立 HTTPS updater 源;此前不得发布或替换生产 `latest.json`。该本地/分阶段例外不适用于后续版本。
|
|
||||||
|
|
||||||
## 分支与发布治理
|
## 分支与发布治理
|
||||||
|
|
||||||
@@ -59,4 +57,4 @@ npm run desktop:build:app
|
|||||||
|
|
||||||
本地缓存相关变更至少执行 `runtime:check`、`desktop:release:check`、`ui:contract`、`type-check`、`test:unit` 和 `build`;若新增 Tauri command、capability、CSP 或底层持久化存储,还需执行 `desktop:build:app` 并在真实桌面 App 中验证缓存清理和诊断入口。
|
本地缓存相关变更至少执行 `runtime:check`、`desktop:release:check`、`ui:contract`、`type-check`、`test:unit` 和 `build`;若新增 Tauri command、capability、CSP 或底层持久化存储,还需执行 `desktop:build:app` 并在真实桌面 App 中验证缓存清理和诊断入口。
|
||||||
|
|
||||||
正式桌面发布构建必须使用组织批准的 updater 签名私钥,updater 签名不可因平台签名例外而关闭。平台签名默认要求 macOS 完成 Apple 签名/公证、Windows 完成组织代码签名、RFC 3161 时间戳和 Authenticode 校验。当前仅批准 v0.1.0 采用受控分发例外:macOS 使用 ad-hoc 签名且不公证,Windows 应用和安装器保持 Authenticode 未签名;制品名、Release Notes、私有发布证据、完整 updater 校验清单和 provenance 必须清楚标注 `UNSIGNED-PLATFORM`,并提示 Gatekeeper/SmartScreen 警告。面向安装用户的 GitHub Release 可按精确版本策略精简为安装包与对应 checksum,但不得因此删除私有 updater 签名制品或验证证据。该例外不自动适用于后续版本。
|
正式桌面发布构建必须使用组织批准的 updater 签名私钥;macOS 必须完成 Apple 签名/公证,Windows 必须完成组织代码签名、RFC 3161 时间戳和 Authenticode 校验。未签名或 ad-hoc 构建只能作为内部验证构建描述。
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
"""Add global ledgers sharing the collaboration engine.
|
|
||||||
|
|
||||||
Revision ID: 20260903_01
|
|
||||||
Revises: 20260716_05
|
|
||||||
"""
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = "20260903_01"
|
|
||||||
down_revision = "20260716_05"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.alter_column("collaboration_files", "study_id", nullable=True)
|
|
||||||
op.add_column("collaboration_files", sa.Column("scope", sa.String(16), nullable=False, server_default="PROJECT"))
|
|
||||||
op.add_column("collaboration_files", sa.Column("ledger_key", sa.String(64), nullable=True))
|
|
||||||
op.add_column("collaboration_files", sa.Column("description", sa.String(500), nullable=False, server_default=""))
|
|
||||||
op.create_unique_constraint("uq_collaboration_file_ledger_key", "collaboration_files", ["ledger_key"])
|
|
||||||
op.create_check_constraint(
|
|
||||||
"ck_collaboration_file_scope", "collaboration_files",
|
|
||||||
"(scope = 'PROJECT' AND study_id IS NOT NULL AND ledger_key IS NULL) OR "
|
|
||||||
"(scope = 'LEDGER' AND study_id IS NULL AND folder_id IS NULL AND ledger_key IS NOT NULL "
|
|
||||||
"AND file_type = 'cell' AND extension = 'xlsx' AND deleted_at IS NULL AND status IN ('ACTIVE', 'ARCHIVED'))",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# Never discard retained ledger files, grants or history during rollback.
|
|
||||||
if op.get_bind().scalar(sa.text("SELECT count(*) FROM collaboration_files WHERE scope = 'LEDGER'")):
|
|
||||||
raise RuntimeError("已有全局台账,不能回滚台账迁移;请保留数据并使用前向修复")
|
|
||||||
op.drop_constraint("ck_collaboration_file_scope", "collaboration_files", type_="check")
|
|
||||||
op.drop_constraint("uq_collaboration_file_ledger_key", "collaboration_files", type_="unique")
|
|
||||||
op.drop_column("collaboration_files", "description")
|
|
||||||
op.drop_column("collaboration_files", "ledger_key")
|
|
||||||
op.drop_column("collaboration_files", "scope")
|
|
||||||
op.alter_column("collaboration_files", "study_id", nullable=False)
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, require_roles
|
|
||||||
from app.schemas.collaboration import (
|
|
||||||
CollaborationEditorConfigRead, CollaborationExportRecord, CollaborationRevisionRead,
|
|
||||||
CollaborationEditRequestRead, CollaborationEditRequestResolve, CollaborationOwnershipTransferRequest,
|
|
||||||
CollaborationShareLinkRead, CollaborationShareLinkUpdate, CollaborationRevisionUpdate,
|
|
||||||
)
|
|
||||||
from app.schemas.ledger import (
|
|
||||||
LedgerAccessSettings, LedgerCandidateRead, LedgerMemberRead, LedgerMemberUpsert, LedgerRead, LedgerRestore, LedgerSettings,
|
|
||||||
)
|
|
||||||
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
|
|
||||||
from app.services import collaboration_service, collaboration_share_service, ledger_service, onlyoffice_collaboration_service, onlyoffice_service
|
|
||||||
|
|
||||||
|
|
||||||
def no_store(response: Response):
|
|
||||||
response.headers["Cache-Control"] = "no-store"
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(dependencies=[Depends(no_store)])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[LedgerRead])
|
|
||||||
async def list_ledgers(db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
|
|
||||||
return await ledger_service.list_ledgers(db, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/initialize", response_model=list[LedgerRead])
|
|
||||||
async def initialize(db: AsyncSession = Depends(get_db_session), user=Depends(require_roles(["ADMIN"]))):
|
|
||||||
return await ledger_service.initialize_ledgers(db, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ledger_id}", response_model=LedgerRead)
|
|
||||||
async def read(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user)
|
|
||||||
return await ledger_service.read_ledger(db, item, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{ledger_id}", response_model=LedgerRead)
|
|
||||||
async def update(ledger_id: uuid.UUID, payload: LedgerSettings, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
|
|
||||||
return await ledger_service.update_settings(db, item, payload, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{ledger_id}/import", response_model=LedgerRead)
|
|
||||||
async def import_template(
|
|
||||||
ledger_id: uuid.UUID, file: UploadFile = File(...), generation: int = Form(..., ge=1),
|
|
||||||
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
|
|
||||||
return await ledger_service.import_content(db, item, file, generation, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ledger_id}/members", response_model=list[LedgerMemberRead])
|
|
||||||
async def members(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True)
|
|
||||||
return await ledger_service.list_members(db, item)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ledger_id}/candidates", response_model=list[LedgerCandidateRead])
|
|
||||||
async def candidates(
|
|
||||||
ledger_id: uuid.UUID, keyword: str = Query(default="", max_length=100),
|
|
||||||
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
await ledger_service.get_ledger(db, ledger_id, user, manage=True)
|
|
||||||
return await ledger_service.candidates(db, keyword)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{ledger_id}/members", status_code=204)
|
|
||||||
async def grant(
|
|
||||||
ledger_id: uuid.UUID, payload: LedgerMemberUpsert,
|
|
||||||
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
|
|
||||||
await ledger_service.set_member(db, item, payload, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{ledger_id}/members/{user_id}", status_code=204)
|
|
||||||
async def revoke(
|
|
||||||
ledger_id: uuid.UUID, user_id: uuid.UUID,
|
|
||||||
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
|
|
||||||
await ledger_service.remove_member(db, item, user_id, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ledger_id}/editor-config", response_model=CollaborationEditorConfigRead)
|
|
||||||
async def editor_config(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
|
|
||||||
return await onlyoffice_collaboration_service.build_editor_config(db, item, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{ledger_id}/access-settings", response_model=LedgerRead)
|
|
||||||
async def access_settings(ledger_id: uuid.UUID, payload: LedgerAccessSettings,
|
|
||||||
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
|
|
||||||
return await ledger_service.update_access_settings(db, item, payload, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ledger_id}/share-link", response_model=CollaborationShareLinkRead)
|
|
||||||
async def share_link(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
|
|
||||||
return await collaboration_share_service.get_share_link(db, item, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{ledger_id}/share-link", response_model=CollaborationShareLinkRead)
|
|
||||||
async def update_share_link(ledger_id: uuid.UUID, payload: CollaborationShareLinkUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
|
|
||||||
return await collaboration_share_service.update_share_link(db, item, payload, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{ledger_id}/edit-requests", response_model=CollaborationEditRequestRead)
|
|
||||||
async def request_edit(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
|
|
||||||
return await collaboration_service.create_edit_request(db, item, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ledger_id}/edit-requests", response_model=list[CollaborationEditRequestRead])
|
|
||||||
async def edit_requests(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True)
|
|
||||||
return await collaboration_service.list_edit_requests(db, item, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{ledger_id}/edit-requests/{request_id}/resolve", response_model=CollaborationEditRequestRead)
|
|
||||||
async def resolve_request(ledger_id: uuid.UUID, request_id: uuid.UUID, payload: CollaborationEditRequestResolve,
|
|
||||||
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
|
|
||||||
return await collaboration_service.resolve_edit_request(db, item, request_id, payload, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{ledger_id}/transfer-ownership", response_model=LedgerRead)
|
|
||||||
async def transfer_owner(ledger_id: uuid.UUID, payload: CollaborationOwnershipTransferRequest,
|
|
||||||
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
|
|
||||||
item = await collaboration_service.transfer_ownership(db, item, payload, user)
|
|
||||||
return await ledger_service.read_ledger(db, item, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{ledger_id}/downloads", status_code=204)
|
|
||||||
async def download_check(
|
|
||||||
ledger_id: uuid.UUID, payload: CollaborationExportRecord,
|
|
||||||
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user)
|
|
||||||
await collaboration_service.record_download(db, item, user, payload.file_type)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ledger_id}/revisions", response_model=list[CollaborationRevisionRead])
|
|
||||||
async def revisions(ledger_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user)):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user)
|
|
||||||
return await collaboration_service.list_revisions(db, item)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{ledger_id}/revisions/{revision_id}/restore", response_model=CollaborationRevisionRead)
|
|
||||||
async def restore(
|
|
||||||
ledger_id: uuid.UUID, revision_id: uuid.UUID, payload: LedgerRestore,
|
|
||||||
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, manage=True, lock=True)
|
|
||||||
return await ledger_service.restore_revision(db, item, revision_id, payload.generation, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{ledger_id}/revisions/{revision_id}", response_model=CollaborationRevisionRead)
|
|
||||||
async def name_revision(
|
|
||||||
ledger_id: uuid.UUID, revision_id: uuid.UUID, payload: CollaborationRevisionUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
|
|
||||||
return await collaboration_service.update_revision(db, item, revision_id, payload, user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ledger_id}/revisions/{revision_id}/download")
|
|
||||||
async def download_revision(
|
|
||||||
ledger_id: uuid.UUID, revision_id: uuid.UUID,
|
|
||||||
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user, lock=True)
|
|
||||||
await collaboration_service.require_file_exporter(db, item, user)
|
|
||||||
revision = await collaboration_service.prepare_revision_preview(db, item, revision_id, user)
|
|
||||||
await collaboration_service.record_download(db, item, user, "xlsx")
|
|
||||||
return FileResponse(revision.file_uri, filename=item.title, media_type=revision.mime_type,
|
|
||||||
headers={"Cache-Control": "no-store"})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{ledger_id}/revisions/{revision_id}/preview-config", response_model=OnlyOfficePreviewConfigRead)
|
|
||||||
async def preview(
|
|
||||||
ledger_id: uuid.UUID, revision_id: uuid.UUID,
|
|
||||||
db: AsyncSession = Depends(get_db_session), user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
item = await ledger_service.get_ledger(db, ledger_id, user)
|
|
||||||
revision = await collaboration_service.prepare_revision_preview(db, item, revision_id, user)
|
|
||||||
await onlyoffice_service.ensure_onlyoffice_available()
|
|
||||||
return onlyoffice_service.build_preview_config(
|
|
||||||
resource_type="collaboration_revision", resource_id=revision.id, file_name=item.title,
|
|
||||||
file_hash=revision.file_hash, user_id=user.id, user_name=user.full_name,
|
|
||||||
)
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.v1 import ledgers
|
|
||||||
|
|
||||||
from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles, onlyoffice, collaboration
|
from app.api.v1 import auth, users, admin_email_settings, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, fees_contracts, drug_shipments, material_equipments, project_milestones, startup, precautions, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, etmf, overview, notifications, desktop_notifications, monitoring_visit_issues, api_permissions, permission_monitoring, permission_templates, system_permissions, study_active_roles, onlyoffice, collaboration
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ api_router.include_router(startup.router, prefix="/studies/{study_id}/startup",
|
|||||||
api_router.include_router(precautions.router, prefix="/studies/{study_id}/shared-library", tags=["precautions"])
|
api_router.include_router(precautions.router, prefix="/studies/{study_id}/shared-library", tags=["precautions"])
|
||||||
api_router.include_router(collaboration.router, prefix="/studies/{study_id}/collaboration", tags=["collaboration"])
|
api_router.include_router(collaboration.router, prefix="/studies/{study_id}/collaboration", tags=["collaboration"])
|
||||||
api_router.include_router(collaboration.public_router, prefix="/collaboration/shares", tags=["collaboration-shares"])
|
api_router.include_router(collaboration.public_router, prefix="/collaboration/shares", tags=["collaboration-shares"])
|
||||||
api_router.include_router(ledgers.router, prefix="/ledgers", tags=["ledgers"])
|
|
||||||
api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"])
|
api_router.include_router(monitoring_visit_issues.router, prefix="/studies/{study_id}/monitoring", tags=["monitoring-visit-issues"])
|
||||||
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
|
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
|
||||||
api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"])
|
api_router.include_router(subject_pds.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-pds"])
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import uuid
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Boolean, CheckConstraint, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func, text
|
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func, text
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -35,22 +35,12 @@ class CollaborationFolder(Base):
|
|||||||
class CollaborationFile(Base):
|
class CollaborationFile(Base):
|
||||||
__tablename__ = "collaboration_files"
|
__tablename__ = "collaboration_files"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint(
|
|
||||||
"(scope = 'PROJECT' AND study_id IS NOT NULL AND ledger_key IS NULL) OR "
|
|
||||||
"(scope = 'LEDGER' AND study_id IS NULL AND folder_id IS NULL AND ledger_key IS NOT NULL "
|
|
||||||
"AND file_type = 'cell' AND extension = 'xlsx' AND deleted_at IS NULL AND status IN ('ACTIVE', 'ARCHIVED'))",
|
|
||||||
name="ck_collaboration_file_scope",
|
|
||||||
),
|
|
||||||
UniqueConstraint("ledger_key", name="uq_collaboration_file_ledger_key"),
|
|
||||||
Index("ix_collaboration_files_study_folder", "study_id", "folder_id"),
|
Index("ix_collaboration_files_study_folder", "study_id", "folder_id"),
|
||||||
Index("ix_collaboration_files_study_status", "study_id", "status"),
|
Index("ix_collaboration_files_study_status", "study_id", "status"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
study_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True)
|
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
|
||||||
scope: Mapped[str] = mapped_column(String(16), nullable=False, default="PROJECT", server_default="PROJECT")
|
|
||||||
ledger_key: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
|
||||||
description: Mapped[str] = mapped_column(String(500), nullable=False, default="", server_default="")
|
|
||||||
folder_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
folder_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("collaboration_folders.id", ondelete="SET NULL"), nullable=True
|
UUID(as_uuid=True), ForeignKey("collaboration_folders.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
|
|
||||||
LedgerRole = Literal["VIEWER", "EDITOR", "MANAGER"]
|
|
||||||
|
|
||||||
|
|
||||||
class LedgerRead(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
title: str
|
|
||||||
description: str
|
|
||||||
status: Literal["ACTIVE", "ARCHIVED"]
|
|
||||||
owner_id: uuid.UUID
|
|
||||||
owner_name: str
|
|
||||||
generation: int
|
|
||||||
updated_at: datetime
|
|
||||||
current_revision_id: uuid.UUID | None
|
|
||||||
current_revision_no: int | None
|
|
||||||
current_revision_file_size: int | None
|
|
||||||
current_revision_mime_type: str | None
|
|
||||||
current_revision_created_at: datetime | None
|
|
||||||
role: LedgerRole
|
|
||||||
can_edit: bool
|
|
||||||
can_manage: bool
|
|
||||||
can_export: bool
|
|
||||||
allow_export: bool
|
|
||||||
allow_edit_request: bool
|
|
||||||
allow_sheet_structure_edit: bool
|
|
||||||
file_type: Literal["cell"] = "cell"
|
|
||||||
extension: Literal["xlsx"] = "xlsx"
|
|
||||||
can_request_edit: bool
|
|
||||||
edit_request_status: Literal["PENDING", "APPROVED", "REJECTED"] | None = None
|
|
||||||
can_transfer_ownership: bool
|
|
||||||
|
|
||||||
|
|
||||||
class LedgerSettings(BaseModel):
|
|
||||||
title: str = Field(min_length=1, max_length=240)
|
|
||||||
description: str = Field(default="", max_length=500)
|
|
||||||
status: Literal["ACTIVE", "ARCHIVED"]
|
|
||||||
owner_id: uuid.UUID
|
|
||||||
allow_export: bool = False
|
|
||||||
allow_edit_request: bool | None = None
|
|
||||||
allow_sheet_structure_edit: bool = False
|
|
||||||
generation: int = Field(ge=1)
|
|
||||||
|
|
||||||
@field_validator("title")
|
|
||||||
@classmethod
|
|
||||||
def trim_title(cls, value):
|
|
||||||
value = value.strip()
|
|
||||||
if not value:
|
|
||||||
raise ValueError("台账名称不能为空")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class LedgerMemberUpsert(BaseModel):
|
|
||||||
user_id: uuid.UUID
|
|
||||||
role: LedgerRole
|
|
||||||
|
|
||||||
|
|
||||||
class LedgerMemberRead(BaseModel):
|
|
||||||
user_id: uuid.UUID
|
|
||||||
full_name: str
|
|
||||||
email: str
|
|
||||||
role: LedgerRole
|
|
||||||
is_active: bool
|
|
||||||
protected_label: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class LedgerCandidateRead(BaseModel):
|
|
||||||
user_id: uuid.UUID
|
|
||||||
full_name: str
|
|
||||||
email: str
|
|
||||||
role_in_study: str
|
|
||||||
can_be_editor: bool = True
|
|
||||||
can_be_manager: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class LedgerAccessSettings(BaseModel):
|
|
||||||
generation: int = Field(ge=1)
|
|
||||||
allow_export: bool | None = None
|
|
||||||
allow_edit_request: bool | None = None
|
|
||||||
allow_sheet_structure_edit: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class LedgerRestore(BaseModel):
|
|
||||||
generation: int = Field(ge=1)
|
|
||||||
@@ -46,7 +46,6 @@ from app.schemas.collaboration import (
|
|||||||
CollaborationRevisionUpdate,
|
CollaborationRevisionUpdate,
|
||||||
)
|
)
|
||||||
from app.services import notification_service
|
from app.services import notification_service
|
||||||
from app.services import ledger_access
|
|
||||||
|
|
||||||
COLLABORATION_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "collaboration"
|
COLLABORATION_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "collaboration"
|
||||||
FILE_TYPE_EXTENSION = {"word": "docx", "cell": "xlsx", "slide": "pptx"}
|
FILE_TYPE_EXTENSION = {"word": "docx", "cell": "xlsx", "slide": "pptx"}
|
||||||
@@ -333,8 +332,6 @@ async def _require_role_assignable(
|
|||||||
|
|
||||||
|
|
||||||
async def can_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
|
async def can_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
|
||||||
if ledger_access.is_ledger(item):
|
|
||||||
return await ledger_access.can_edit(db, item, user)
|
|
||||||
if is_system_admin(user):
|
if is_system_admin(user):
|
||||||
return True
|
return True
|
||||||
membership = await member_crud.get_member(db, item.study_id, user.id)
|
membership = await member_crud.get_member(db, item.study_id, user.id)
|
||||||
@@ -345,8 +342,6 @@ async def can_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool
|
|||||||
|
|
||||||
|
|
||||||
async def can_manage_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
|
async def can_manage_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
|
||||||
if ledger_access.is_ledger(item):
|
|
||||||
return await ledger_access.can_manage(db, item, user)
|
|
||||||
if is_system_admin(user):
|
if is_system_admin(user):
|
||||||
return True
|
return True
|
||||||
membership = await member_crud.get_member(db, item.study_id, user.id)
|
membership = await member_crud.get_member(db, item.study_id, user.id)
|
||||||
@@ -356,8 +351,6 @@ async def can_manage_file(db: AsyncSession, item: CollaborationFile, user) -> bo
|
|||||||
|
|
||||||
|
|
||||||
async def can_export_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
|
async def can_export_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
|
||||||
if ledger_access.is_ledger(item):
|
|
||||||
return await ledger_access.can_export(db, item, user)
|
|
||||||
if is_system_admin(user):
|
if is_system_admin(user):
|
||||||
return True
|
return True
|
||||||
membership = await member_crud.get_member(db, item.study_id, user.id)
|
membership = await member_crud.get_member(db, item.study_id, user.id)
|
||||||
@@ -369,8 +362,6 @@ async def can_export_file(db: AsyncSession, item: CollaborationFile, user) -> bo
|
|||||||
|
|
||||||
|
|
||||||
async def can_create_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
|
async def can_create_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
|
||||||
if ledger_access.is_ledger(item):
|
|
||||||
return False
|
|
||||||
if is_system_admin(user):
|
if is_system_admin(user):
|
||||||
return True
|
return True
|
||||||
membership = await member_crud.get_member(db, item.study_id, user.id)
|
membership = await member_crud.get_member(db, item.study_id, user.id)
|
||||||
@@ -396,12 +387,6 @@ async def edit_request_status(
|
|||||||
|
|
||||||
|
|
||||||
async def can_request_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
|
async def can_request_edit_file(db: AsyncSession, item: CollaborationFile, user) -> bool:
|
||||||
if ledger_access.is_ledger(item):
|
|
||||||
return (
|
|
||||||
item.status == "ACTIVE" and item.allow_edit_request
|
|
||||||
and await ledger_access.role_for(db, item, user) == "VIEWER"
|
|
||||||
and await edit_request_status(db, item, user.id) != "PENDING"
|
|
||||||
)
|
|
||||||
if not item.allow_edit_request or await can_edit_file(db, item, user):
|
if not item.allow_edit_request or await can_edit_file(db, item, user):
|
||||||
return False
|
return False
|
||||||
membership = await member_crud.get_member(db, item.study_id, user.id)
|
membership = await member_crud.get_member(db, item.study_id, user.id)
|
||||||
@@ -413,8 +398,6 @@ async def can_request_edit_file(db: AsyncSession, item: CollaborationFile, user)
|
|||||||
|
|
||||||
|
|
||||||
async def can_transfer_ownership(db: AsyncSession, item: CollaborationFile, user) -> bool:
|
async def can_transfer_ownership(db: AsyncSession, item: CollaborationFile, user) -> bool:
|
||||||
if ledger_access.is_ledger(item):
|
|
||||||
return bool(user and user.is_active and (is_system_admin(user) or item.owner_id == user.id))
|
|
||||||
if is_system_admin(user):
|
if is_system_admin(user):
|
||||||
return True
|
return True
|
||||||
membership = await member_crud.get_member(db, item.study_id, user.id)
|
membership = await member_crud.get_member(db, item.study_id, user.id)
|
||||||
@@ -552,7 +535,7 @@ async def _persist_revision_bytes(
|
|||||||
select(func.max(CollaborationRevision.revision_no)).where(CollaborationRevision.file_id == locked.id)
|
select(func.max(CollaborationRevision.revision_no)).where(CollaborationRevision.file_id == locked.id)
|
||||||
)
|
)
|
||||||
revision_no = int(last_no or 0) + 1
|
revision_no = int(last_no or 0) + 1
|
||||||
directory = COLLABORATION_ROOT / ("ledgers" if ledger_access.is_ledger(locked) else str(locked.study_id)) / str(locked.id)
|
directory = COLLABORATION_ROOT / str(locked.study_id) / str(locked.id)
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
destination = directory / f"{uuid.uuid4()}.{locked.extension}"
|
destination = directory / f"{uuid.uuid4()}.{locked.extension}"
|
||||||
async with aiofiles.open(destination, "wb") as stream:
|
async with aiofiles.open(destination, "wb") as stream:
|
||||||
@@ -858,8 +841,6 @@ async def prepare_download(db: AsyncSession, item: CollaborationFile, user) -> C
|
|||||||
|
|
||||||
|
|
||||||
async def move_to_trash(db: AsyncSession, item: CollaborationFile, user) -> None:
|
async def move_to_trash(db: AsyncSession, item: CollaborationFile, user) -> None:
|
||||||
if ledger_access.is_ledger(item):
|
|
||||||
raise HTTPException(403, "台账禁止删除,请使用归档")
|
|
||||||
await require_file_manager(db, item, user)
|
await require_file_manager(db, item, user)
|
||||||
item.status = "DELETED"
|
item.status = "DELETED"
|
||||||
item.deleted_at = datetime.now(timezone.utc)
|
item.deleted_at = datetime.now(timezone.utc)
|
||||||
@@ -918,8 +899,6 @@ async def delete_revision(
|
|||||||
revision_id: uuid.UUID,
|
revision_id: uuid.UUID,
|
||||||
user,
|
user,
|
||||||
) -> None:
|
) -> None:
|
||||||
if ledger_access.is_ledger(item):
|
|
||||||
raise HTTPException(403, "台账历史版本禁止删除")
|
|
||||||
await require_file_manager(db, item, user)
|
await require_file_manager(db, item, user)
|
||||||
revision = await get_revision_or_404(db, item, revision_id)
|
revision = await get_revision_or_404(db, item, revision_id)
|
||||||
if item.current_revision_id == revision.id:
|
if item.current_revision_id == revision.id:
|
||||||
@@ -986,19 +965,10 @@ async def copy_revision(
|
|||||||
async def restore_revision(
|
async def restore_revision(
|
||||||
db: AsyncSession, item: CollaborationFile, revision_id: uuid.UUID, user, change_summary: str | None
|
db: AsyncSession, item: CollaborationFile, revision_id: uuid.UUID, user, change_summary: str | None
|
||||||
) -> CollaborationRevision:
|
) -> CollaborationRevision:
|
||||||
if ledger_access.is_ledger(item):
|
|
||||||
await ledger_access.require_access(db, item, user, manage=True)
|
|
||||||
await require_file_editor(db, item, user)
|
await require_file_editor(db, item, user)
|
||||||
revision = await get_revision_or_404(db, item, revision_id)
|
revision = await get_revision_or_404(db, item, revision_id)
|
||||||
async with aiofiles.open(revision.file_uri, "rb") as stream:
|
async with aiofiles.open(revision.file_uri, "rb") as stream:
|
||||||
content = await stream.read()
|
content = await stream.read()
|
||||||
if ledger_access.is_ledger(item):
|
|
||||||
content, _ = apply_workbook_structure_policy(
|
|
||||||
content, file_id=item.id, allow_sheet_structure_edit=item.allow_sheet_structure_edit,
|
|
||||||
# A historical revision may still carry CTMS protection after the
|
|
||||||
# current ledger was unlocked. Apply today's policy on restoration.
|
|
||||||
protection_backup=item.sheet_structure_protection_backup or "",
|
|
||||||
)
|
|
||||||
restored, created = await append_revision(
|
restored, created = await append_revision(
|
||||||
db,
|
db,
|
||||||
item,
|
item,
|
||||||
@@ -1122,14 +1092,9 @@ async def create_edit_request(
|
|||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="您已拥有此文件的编辑权限")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="您已拥有此文件的编辑权限")
|
||||||
if not item.allow_edit_request:
|
if not item.allow_edit_request:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="此文件未开放编辑权限申请")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="此文件未开放编辑权限申请")
|
||||||
if ledger_access.is_ledger(item):
|
membership = await member_crud.get_member(db, item.study_id, user.id)
|
||||||
await ledger_access.require_access(db, item, user)
|
if not membership or not membership.is_active:
|
||||||
if item.status != "ACTIVE":
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前项目成员可以申请编辑权限")
|
||||||
raise HTTPException(409, "已归档台账不能申请编辑权限")
|
|
||||||
else:
|
|
||||||
membership = await member_crud.get_member(db, item.study_id, user.id)
|
|
||||||
if not membership or not membership.is_active:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有当前项目成员可以申请编辑权限")
|
|
||||||
pending = await db.scalar(
|
pending = await db.scalar(
|
||||||
select(CollaborationEditRequest).where(
|
select(CollaborationEditRequest).where(
|
||||||
CollaborationEditRequest.file_id == item.id,
|
CollaborationEditRequest.file_id == item.id,
|
||||||
@@ -1142,41 +1107,40 @@ async def create_edit_request(
|
|||||||
request = CollaborationEditRequest(file_id=item.id, requester_id=user.id)
|
request = CollaborationEditRequest(file_id=item.id, requester_id=user.id)
|
||||||
db.add(request)
|
db.add(request)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
if not ledger_access.is_ledger(item):
|
manager_ids = set((await db.scalars(
|
||||||
manager_ids = set((await db.scalars(
|
select(StudyMember.user_id)
|
||||||
select(StudyMember.user_id)
|
.outerjoin(
|
||||||
.outerjoin(
|
CollaborationMember,
|
||||||
CollaborationMember,
|
and_(
|
||||||
and_(
|
CollaborationMember.file_id == item.id,
|
||||||
CollaborationMember.file_id == item.id,
|
CollaborationMember.user_id == StudyMember.user_id,
|
||||||
CollaborationMember.user_id == StudyMember.user_id,
|
),
|
||||||
),
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
StudyMember.study_id == item.study_id,
|
|
||||||
StudyMember.is_active.is_(True),
|
|
||||||
or_(
|
|
||||||
StudyMember.user_id == item.owner_id,
|
|
||||||
CollaborationMember.role == "MANAGER",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)).all())
|
|
||||||
manager_ids.add(item.owner_id)
|
|
||||||
requester_name = str(user.full_name or user.email or "项目成员")
|
|
||||||
await notification_service.create_recipient_notifications(
|
|
||||||
db,
|
|
||||||
study_id=item.study_id,
|
|
||||||
recipient_ids=manager_ids,
|
|
||||||
category="COLLABORATION_EDIT_REQUEST",
|
|
||||||
priority="NORMAL",
|
|
||||||
title="新的编辑权限申请",
|
|
||||||
message=f"{requester_name} 申请编辑“{item.title}”",
|
|
||||||
action_path=f"/knowledge/collaboration?editRequestFile={item.id}",
|
|
||||||
source_type="COLLABORATION_EDIT_REQUEST",
|
|
||||||
source_id=str(request.id),
|
|
||||||
dedupe_key=f"collaboration-edit-request:{request.id}",
|
|
||||||
source_version="PENDING",
|
|
||||||
)
|
)
|
||||||
|
.where(
|
||||||
|
StudyMember.study_id == item.study_id,
|
||||||
|
StudyMember.is_active.is_(True),
|
||||||
|
or_(
|
||||||
|
StudyMember.user_id == item.owner_id,
|
||||||
|
CollaborationMember.role == "MANAGER",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)).all())
|
||||||
|
manager_ids.add(item.owner_id)
|
||||||
|
requester_name = str(user.full_name or user.email or "项目成员")
|
||||||
|
await notification_service.create_recipient_notifications(
|
||||||
|
db,
|
||||||
|
study_id=item.study_id,
|
||||||
|
recipient_ids=manager_ids,
|
||||||
|
category="COLLABORATION_EDIT_REQUEST",
|
||||||
|
priority="NORMAL",
|
||||||
|
title="新的编辑权限申请",
|
||||||
|
message=f"{requester_name} 申请编辑“{item.title}”",
|
||||||
|
action_path=f"/knowledge/collaboration?editRequestFile={item.id}",
|
||||||
|
source_type="COLLABORATION_EDIT_REQUEST",
|
||||||
|
source_id=str(request.id),
|
||||||
|
dedupe_key=f"collaboration-edit-request:{request.id}",
|
||||||
|
source_version="PENDING",
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(request)
|
await db.refresh(request)
|
||||||
return _edit_request_read(request, user)
|
return _edit_request_read(request, user)
|
||||||
@@ -1215,18 +1179,11 @@ async def resolve_edit_request(
|
|||||||
if request.status != "PENDING":
|
if request.status != "PENDING":
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="编辑权限申请已处理")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="编辑权限申请已处理")
|
||||||
requester = await db.get(User, request.requester_id)
|
requester = await db.get(User, request.requester_id)
|
||||||
if ledger_access.is_ledger(item):
|
membership = await member_crud.get_member(db, item.study_id, request.requester_id)
|
||||||
if item.status != "ACTIVE" or not await ledger_access.role_for(db, item, requester):
|
if not requester or not membership or not membership.is_active:
|
||||||
raise HTTPException(422, "申请人已无台账访问权限或台账已归档")
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="申请人已不是当前项目的有效成员")
|
||||||
else:
|
|
||||||
membership = await member_crud.get_member(db, item.study_id, request.requester_id)
|
|
||||||
if not requester or not membership or not membership.is_active:
|
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="申请人已不是当前项目的有效成员")
|
|
||||||
if payload.status == "APPROVED":
|
if payload.status == "APPROVED":
|
||||||
if ledger_access.is_ledger(item):
|
await _require_role_assignable(db, item.study_id, requester, membership, "EDITOR")
|
||||||
item.generation += 1
|
|
||||||
else:
|
|
||||||
await _require_role_assignable(db, item.study_id, requester, membership, "EDITOR")
|
|
||||||
member = await db.scalar(select(CollaborationMember).where(
|
member = await db.scalar(select(CollaborationMember).where(
|
||||||
CollaborationMember.file_id == item.id,
|
CollaborationMember.file_id == item.id,
|
||||||
CollaborationMember.user_id == request.requester_id,
|
CollaborationMember.user_id == request.requester_id,
|
||||||
@@ -1244,31 +1201,30 @@ async def resolve_edit_request(
|
|||||||
request.status = payload.status
|
request.status = payload.status
|
||||||
request.resolved_by = user.id
|
request.resolved_by = user.id
|
||||||
request.resolved_at = datetime.now(timezone.utc)
|
request.resolved_at = datetime.now(timezone.utc)
|
||||||
if not ledger_access.is_ledger(item):
|
approved = payload.status == "APPROVED"
|
||||||
approved = payload.status == "APPROVED"
|
await notification_service.create_recipient_notifications(
|
||||||
await notification_service.create_recipient_notifications(
|
db,
|
||||||
db,
|
study_id=item.study_id,
|
||||||
study_id=item.study_id,
|
recipient_ids=[request.requester_id],
|
||||||
recipient_ids=[request.requester_id],
|
category="COLLABORATION_EDIT_REQUEST_RESULT",
|
||||||
category="COLLABORATION_EDIT_REQUEST_RESULT",
|
priority="NORMAL",
|
||||||
priority="NORMAL",
|
title="编辑权限申请已通过" if approved else "编辑权限申请未通过",
|
||||||
title="编辑权限申请已通过" if approved else "编辑权限申请未通过",
|
message=f"您对“{item.title}”的编辑权限申请已{'通过' if approved else '被拒绝'}",
|
||||||
message=f"您对“{item.title}”的编辑权限申请已{'通过' if approved else '被拒绝'}",
|
action_path=(
|
||||||
action_path=(
|
f"/knowledge/collaboration/{item.id}"
|
||||||
f"/knowledge/collaboration/{item.id}"
|
if approved
|
||||||
if approved
|
else "/knowledge/collaboration"
|
||||||
else "/knowledge/collaboration"
|
),
|
||||||
),
|
source_type="COLLABORATION_EDIT_REQUEST_RESULT",
|
||||||
source_type="COLLABORATION_EDIT_REQUEST_RESULT",
|
source_id=str(request.id),
|
||||||
source_id=str(request.id),
|
dedupe_key=f"collaboration-edit-request-result:{request.id}",
|
||||||
dedupe_key=f"collaboration-edit-request-result:{request.id}",
|
requires_action=False,
|
||||||
requires_action=False,
|
)
|
||||||
)
|
await notification_service.resolve_source_notifications(
|
||||||
await notification_service.resolve_source_notifications(
|
db,
|
||||||
db,
|
source_type="COLLABORATION_EDIT_REQUEST",
|
||||||
source_type="COLLABORATION_EDIT_REQUEST",
|
source_id=str(request.id),
|
||||||
source_id=str(request.id),
|
)
|
||||||
)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(request)
|
await db.refresh(request)
|
||||||
return _edit_request_read(request, requester)
|
return _edit_request_read(request, requester)
|
||||||
@@ -1286,16 +1242,11 @@ async def transfer_ownership(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
|
||||||
if payload.new_owner_id == locked.owner_id:
|
if payload.new_owner_id == locked.owner_id:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="所选联系人已经是文档所有者")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="所选联系人已经是文档所有者")
|
||||||
|
membership = await member_crud.get_member(db, locked.study_id, payload.new_owner_id)
|
||||||
target = await db.get(User, payload.new_owner_id)
|
target = await db.get(User, payload.new_owner_id)
|
||||||
if ledger_access.is_ledger(locked):
|
if not target or not membership or not membership.is_active:
|
||||||
if not target or not target.is_active:
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="只能转让给当前项目的有效成员")
|
||||||
raise HTTPException(422, "只能转让给有效的系统账号")
|
await _require_role_assignable(db, locked.study_id, target, membership, "MANAGER")
|
||||||
locked.generation += 1
|
|
||||||
else:
|
|
||||||
membership = await member_crud.get_member(db, locked.study_id, payload.new_owner_id)
|
|
||||||
if not target or not membership or not membership.is_active:
|
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="只能转让给当前项目的有效成员")
|
|
||||||
await _require_role_assignable(db, locked.study_id, target, membership, "MANAGER")
|
|
||||||
previous_owner_id = locked.owner_id
|
previous_owner_id = locked.owner_id
|
||||||
member = await db.scalar(select(CollaborationMember).where(
|
member = await db.scalar(select(CollaborationMember).where(
|
||||||
CollaborationMember.file_id == locked.id,
|
CollaborationMember.file_id == locked.id,
|
||||||
@@ -1317,9 +1268,6 @@ async def transfer_ownership(
|
|||||||
))
|
))
|
||||||
if previous_owner_member:
|
if previous_owner_member:
|
||||||
previous_owner_member.role = "MANAGER"
|
previous_owner_member.role = "MANAGER"
|
||||||
elif ledger_access.is_ledger(locked):
|
|
||||||
db.add(CollaborationMember(file_id=locked.id, user_id=previous_owner_id,
|
|
||||||
role="MANAGER", invited_by=user.id))
|
|
||||||
locked.owner_id = payload.new_owner_id
|
locked.owner_id = payload.new_owner_id
|
||||||
locked.updated_at = datetime.now(timezone.utc)
|
locked.updated_at = datetime.now(timezone.utc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from app.schemas.collaboration import (
|
|||||||
CollaborationShareLinkRead,
|
CollaborationShareLinkRead,
|
||||||
CollaborationShareLinkUpdate,
|
CollaborationShareLinkUpdate,
|
||||||
)
|
)
|
||||||
from app.services import collaboration_service, ledger_access
|
from app.services import collaboration_service
|
||||||
|
|
||||||
|
|
||||||
SHARE_PATH = "/collaboration/share"
|
SHARE_PATH = "/collaboration/share"
|
||||||
@@ -177,11 +177,6 @@ async def update_share_link(
|
|||||||
link.failed_attempts = 0
|
link.failed_attempts = 0
|
||||||
link.last_failed_at = None
|
link.last_failed_at = None
|
||||||
link.locked_until = None
|
link.locked_until = None
|
||||||
if ledger_access.is_ledger(item):
|
|
||||||
# Ledger routes hold the same file lock as callbacks before changing a link.
|
|
||||||
item.generation += 1
|
|
||||||
item.updated_at = now
|
|
||||||
link.token_version += 1
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(link)
|
await db.refresh(link)
|
||||||
return share_link_read(link)
|
return share_link_read(link)
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
"""Account-level ledger permissions, independent of study membership."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
from fastapi import HTTPException
|
|
||||||
|
|
||||||
from app.core.deps import is_system_admin
|
|
||||||
from app.models.collaboration import CollaborationMember
|
|
||||||
|
|
||||||
|
|
||||||
def is_ledger(item) -> bool:
|
|
||||||
return getattr(item, "scope", "PROJECT") == "LEDGER"
|
|
||||||
|
|
||||||
|
|
||||||
async def role_for(db, item, user) -> str | None:
|
|
||||||
if not user or not user.is_active:
|
|
||||||
return None
|
|
||||||
if is_system_admin(user) or item.owner_id == user.id:
|
|
||||||
return "MANAGER"
|
|
||||||
return await db.scalar(select(CollaborationMember.role).where(
|
|
||||||
CollaborationMember.file_id == item.id, CollaborationMember.user_id == user.id,
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
async def can_edit(db, item, user) -> bool:
|
|
||||||
return item.status == "ACTIVE" and await role_for(db, item, user) in {"EDITOR", "MANAGER"}
|
|
||||||
|
|
||||||
|
|
||||||
async def can_manage(db, item, user) -> bool:
|
|
||||||
return await role_for(db, item, user) == "MANAGER"
|
|
||||||
|
|
||||||
|
|
||||||
async def can_export(db, item, user) -> bool:
|
|
||||||
role = await role_for(db, item, user)
|
|
||||||
return role == "MANAGER" or (role in {"EDITOR", "VIEWER"} and item.allow_export)
|
|
||||||
|
|
||||||
|
|
||||||
async def require_access(db, item, user, *, manage=False):
|
|
||||||
role = await role_for(db, item, user)
|
|
||||||
if role not in {"VIEWER", "EDITOR", "MANAGER"}:
|
|
||||||
raise HTTPException(404, "台账不存在或未获授权")
|
|
||||||
if manage and role != "MANAGER":
|
|
||||||
raise HTTPException(403, "仅台账管理人员可以执行此操作")
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
"""Global ledger metadata and grants; reuse collaboration revisions and sessions."""
|
|
||||||
import io
|
|
||||||
import uuid
|
|
||||||
import zipfile
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import aiofiles
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy import or_, select, text
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.deps import is_system_admin
|
|
||||||
from app.models.collaboration import CollaborationFile, CollaborationMember, CollaborationRevision
|
|
||||||
from app.models.user import User, UserStatus
|
|
||||||
from app.schemas.ledger import LedgerRead, LedgerSettings
|
|
||||||
from app.services import collaboration_service as collaboration
|
|
||||||
from app.services import ledger_access
|
|
||||||
|
|
||||||
LEDGER_TEMPLATES = (
|
|
||||||
("ledger-1", "医学事务部合同台账明细表.xlsx", "contract-ledger.xlsx", "记录合同编号、项目代码与合同内容"),
|
|
||||||
("ledger-2", "医学事务部临床运营项目编号.xlsx", "clinical-project-register.xlsx", "记录项目编号、产品信息与生效日期"),
|
|
||||||
)
|
|
||||||
TEMPLATE_ROOT = Path(__file__).resolve().parents[1] / "templates" / "ledgers"
|
|
||||||
|
|
||||||
|
|
||||||
async def get_ledger(db, ledger_id, user, *, manage=False, lock=False):
|
|
||||||
stmt = select(CollaborationFile).where(
|
|
||||||
CollaborationFile.id == ledger_id, CollaborationFile.scope == "LEDGER",
|
|
||||||
).execution_options(populate_existing=True)
|
|
||||||
if lock:
|
|
||||||
stmt = stmt.with_for_update()
|
|
||||||
item = await db.scalar(stmt)
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(404, "台账不存在或未获授权")
|
|
||||||
await ledger_access.require_access(db, item, user, manage=manage)
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
async def read_ledger(db, item, user):
|
|
||||||
role = await ledger_access.role_for(db, item, user)
|
|
||||||
await ledger_access.require_access(db, item, user)
|
|
||||||
owner = await db.get(User, item.owner_id)
|
|
||||||
revision = await db.get(CollaborationRevision, item.current_revision_id) if item.current_revision_id else None
|
|
||||||
return LedgerRead(
|
|
||||||
id=item.id, title=item.title, description=item.description, status=item.status,
|
|
||||||
owner_id=item.owner_id, owner_name=owner.full_name if owner else "",
|
|
||||||
generation=item.generation, updated_at=item.updated_at,
|
|
||||||
current_revision_id=item.current_revision_id,
|
|
||||||
current_revision_no=revision.revision_no if revision else None,
|
|
||||||
current_revision_file_size=revision.file_size if revision else None,
|
|
||||||
current_revision_mime_type=revision.mime_type if revision else None,
|
|
||||||
current_revision_created_at=revision.created_at if revision else None,
|
|
||||||
role=role, can_edit=await ledger_access.can_edit(db, item, user),
|
|
||||||
can_manage=role == "MANAGER", can_export=await ledger_access.can_export(db, item, user),
|
|
||||||
allow_export=item.allow_export, allow_sheet_structure_edit=item.allow_sheet_structure_edit,
|
|
||||||
allow_edit_request=item.allow_edit_request,
|
|
||||||
can_request_edit=await collaboration.can_request_edit_file(db, item, user),
|
|
||||||
edit_request_status=await collaboration.edit_request_status(db, item, user.id),
|
|
||||||
can_transfer_ownership=await collaboration.can_transfer_ownership(db, item, user),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_ledgers(db, user):
|
|
||||||
stmt = select(CollaborationFile).where(CollaborationFile.scope == "LEDGER")
|
|
||||||
if not is_system_admin(user):
|
|
||||||
granted = select(CollaborationMember.file_id).where(
|
|
||||||
CollaborationMember.user_id == user.id,
|
|
||||||
CollaborationMember.role.in_(["VIEWER", "EDITOR", "MANAGER"]),
|
|
||||||
)
|
|
||||||
stmt = stmt.where(or_(CollaborationFile.owner_id == user.id, CollaborationFile.id.in_(granted)))
|
|
||||||
rows = (await db.scalars(stmt.order_by(CollaborationFile.ledger_key))).all()
|
|
||||||
return [await read_ledger(db, item, user) for item in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def initialize_ledgers(db, user):
|
|
||||||
if not is_system_admin(user):
|
|
||||||
raise HTTPException(403, "仅系统管理员可以初始化台账")
|
|
||||||
# Serialize initialization across administrators; unique keys also prevent duplicates.
|
|
||||||
if db.get_bind().dialect.name == "postgresql":
|
|
||||||
await db.execute(text("SELECT pg_advisory_xact_lock(2026090301)"))
|
|
||||||
for key, title, template_name, description in LEDGER_TEMPLATES:
|
|
||||||
if await db.scalar(select(CollaborationFile.id).where(CollaborationFile.ledger_key == key)):
|
|
||||||
continue
|
|
||||||
item = CollaborationFile(
|
|
||||||
id=uuid.uuid4(), scope="LEDGER", ledger_key=key, study_id=None, folder_id=None,
|
|
||||||
title=title, description=description, file_type="cell", extension="xlsx", owner_id=user.id,
|
|
||||||
status="ACTIVE", generation=1, allow_sheet_structure_edit=False, allow_export=False,
|
|
||||||
)
|
|
||||||
db.add(item)
|
|
||||||
await db.flush()
|
|
||||||
content, backup = collaboration.apply_workbook_structure_policy(
|
|
||||||
(TEMPLATE_ROOT / template_name).read_bytes(), file_id=item.id,
|
|
||||||
allow_sheet_structure_edit=False, protection_backup=None,
|
|
||||||
)
|
|
||||||
item.sheet_structure_protection_backup = backup
|
|
||||||
await collaboration.append_revision(db, item, content, source="CREATE", created_by=user.id)
|
|
||||||
await db.commit()
|
|
||||||
return await list_ledgers(db, user)
|
|
||||||
|
|
||||||
|
|
||||||
def check_generation(item, generation):
|
|
||||||
if item.generation != generation:
|
|
||||||
raise HTTPException(409, "台账已发生变化,请刷新后重试")
|
|
||||||
|
|
||||||
|
|
||||||
def advance_generation(item):
|
|
||||||
item.generation += 1
|
|
||||||
item.updated_at = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
async def active_user(db, user_id):
|
|
||||||
user = await db.get(User, user_id)
|
|
||||||
if not user or not user.is_active:
|
|
||||||
raise HTTPException(422, "请选择有效的系统账号")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def update_settings(db, item, payload, user):
|
|
||||||
await ledger_access.require_access(db, item, user, manage=True)
|
|
||||||
check_generation(item, payload.generation)
|
|
||||||
if payload.owner_id != item.owner_id:
|
|
||||||
if not is_system_admin(user):
|
|
||||||
raise HTTPException(403, "仅系统管理员可以变更台账负责人")
|
|
||||||
await active_user(db, payload.owner_id)
|
|
||||||
if payload.allow_sheet_structure_edit != item.allow_sheet_structure_edit:
|
|
||||||
revision = await db.get(CollaborationRevision, item.current_revision_id)
|
|
||||||
if not revision or not Path(revision.file_uri).is_file():
|
|
||||||
raise HTTPException(404, "台账内容不存在")
|
|
||||||
async with aiofiles.open(revision.file_uri, "rb") as stream:
|
|
||||||
content = await stream.read(settings.COLLABORATION_MAX_FILE_BYTES + 1)
|
|
||||||
content, backup = collaboration.apply_workbook_structure_policy(
|
|
||||||
content, file_id=item.id, allow_sheet_structure_edit=payload.allow_sheet_structure_edit,
|
|
||||||
protection_backup=item.sheet_structure_protection_backup,
|
|
||||||
)
|
|
||||||
item.sheet_structure_protection_backup = backup
|
|
||||||
await collaboration.append_revision(db, item, content, source="PERMISSION_CHANGE", created_by=user.id)
|
|
||||||
item.title = collaboration._safe_title(payload.title, "xlsx")
|
|
||||||
item.description = payload.description.strip()
|
|
||||||
item.status = payload.status
|
|
||||||
item.owner_id = payload.owner_id
|
|
||||||
item.allow_export = payload.allow_export
|
|
||||||
if payload.allow_edit_request is not None:
|
|
||||||
item.allow_edit_request = payload.allow_edit_request
|
|
||||||
item.allow_sheet_structure_edit = payload.allow_sheet_structure_edit
|
|
||||||
advance_generation(item)
|
|
||||||
await db.commit()
|
|
||||||
return await read_ledger(db, item, user)
|
|
||||||
|
|
||||||
|
|
||||||
async def update_access_settings(db, item, payload, user):
|
|
||||||
values = dict(title=item.title, description=item.description, status=item.status,
|
|
||||||
owner_id=item.owner_id, allow_export=item.allow_export,
|
|
||||||
allow_edit_request=item.allow_edit_request,
|
|
||||||
allow_sheet_structure_edit=item.allow_sheet_structure_edit)
|
|
||||||
values.update(payload.model_dump(exclude_none=True))
|
|
||||||
return await update_settings(db, item, LedgerSettings(**values), user)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_members(db, item):
|
|
||||||
rows = (await db.execute(select(CollaborationMember, User).join(
|
|
||||||
User, User.id == CollaborationMember.user_id,
|
|
||||||
).where(CollaborationMember.file_id == item.id).order_by(User.full_name))).all()
|
|
||||||
grants = {user.id: dict(user_id=user.id, full_name=user.full_name, email=user.email,
|
|
||||||
role=member.role, is_active=user.is_active) for member, user in rows}
|
|
||||||
implicit = (await db.scalars(select(User).where(or_(
|
|
||||||
User.id == item.owner_id,
|
|
||||||
(User.is_admin.is_(True)) & (User.status == UserStatus.ACTIVE),
|
|
||||||
)).order_by(User.full_name))).all()
|
|
||||||
for user in implicit:
|
|
||||||
grants[user.id] = dict(user_id=user.id, full_name=user.full_name, email=user.email,
|
|
||||||
role="MANAGER", is_active=user.is_active,
|
|
||||||
protected_label="所有者" if user.id == item.owner_id else "系统管理员")
|
|
||||||
return sorted(grants.values(), key=lambda member: (
|
|
||||||
member["user_id"] != item.owner_id, member["role"] != "MANAGER", member["full_name"],
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
async def set_member(db, item, payload, user):
|
|
||||||
await ledger_access.require_access(db, item, user, manage=True)
|
|
||||||
target = await active_user(db, payload.user_id)
|
|
||||||
if target.id == item.owner_id or is_system_admin(target):
|
|
||||||
raise HTTPException(422, "负责人和系统管理员已拥有管理权限")
|
|
||||||
member = await db.scalar(select(CollaborationMember).where(
|
|
||||||
CollaborationMember.file_id == item.id, CollaborationMember.user_id == target.id,
|
|
||||||
))
|
|
||||||
if member and member.role == payload.role:
|
|
||||||
return
|
|
||||||
if member:
|
|
||||||
member.role = payload.role
|
|
||||||
else:
|
|
||||||
db.add(CollaborationMember(file_id=item.id, user_id=target.id, role=payload.role, invited_by=user.id))
|
|
||||||
advance_generation(item)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def remove_member(db, item, user_id, user):
|
|
||||||
await ledger_access.require_access(db, item, user, manage=True)
|
|
||||||
if user_id == item.owner_id:
|
|
||||||
raise HTTPException(422, "不能移除台账负责人")
|
|
||||||
target = await db.get(User, user_id)
|
|
||||||
if target and is_system_admin(target):
|
|
||||||
raise HTTPException(422, "系统管理员始终拥有管理权限")
|
|
||||||
member = await db.scalar(select(CollaborationMember).where(
|
|
||||||
CollaborationMember.file_id == item.id, CollaborationMember.user_id == user_id,
|
|
||||||
))
|
|
||||||
if member:
|
|
||||||
await db.delete(member)
|
|
||||||
advance_generation(item)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def candidates(db, keyword):
|
|
||||||
stmt = select(User).where(User.status == UserStatus.ACTIVE)
|
|
||||||
if keyword:
|
|
||||||
pattern = f"%{keyword}%"
|
|
||||||
stmt = stmt.where(or_(User.full_name.ilike(pattern), User.email.ilike(pattern)))
|
|
||||||
rows = (await db.scalars(stmt.order_by(User.full_name, User.id))).all()
|
|
||||||
return [dict(user_id=user.id, full_name=user.full_name, email=user.email,
|
|
||||||
role_in_study="系统管理员" if user.is_admin else "系统账号") for user in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def import_content(db, item, upload, generation, user):
|
|
||||||
await ledger_access.require_access(db, item, user, manage=True)
|
|
||||||
check_generation(item, generation)
|
|
||||||
if item.status != "ACTIVE":
|
|
||||||
raise HTTPException(409, "请先启用台账再导入")
|
|
||||||
if Path(upload.filename or "").suffix.lower() != ".xlsx":
|
|
||||||
raise HTTPException(415, "台账仅支持 XLSX 文件")
|
|
||||||
content = await upload.read(settings.COLLABORATION_MAX_FILE_BYTES + 1)
|
|
||||||
if not content or len(content) > settings.COLLABORATION_MAX_FILE_BYTES:
|
|
||||||
raise HTTPException(413, "台账文件为空或超出大小限制")
|
|
||||||
try:
|
|
||||||
with zipfile.ZipFile(io.BytesIO(content)) as package:
|
|
||||||
if "xl/workbook.xml" not in package.namelist():
|
|
||||||
raise ValueError()
|
|
||||||
if sum(info.file_size for info in package.infolist()) > settings.COLLABORATION_MAX_FILE_BYTES * 20:
|
|
||||||
raise ValueError()
|
|
||||||
if any("vbaproject" in name.lower() for name in package.namelist()):
|
|
||||||
raise ValueError()
|
|
||||||
except (zipfile.BadZipFile, ValueError):
|
|
||||||
raise HTTPException(422, "XLSX 文件无效、包含宏或解压后过大")
|
|
||||||
content, backup = collaboration.apply_workbook_structure_policy(
|
|
||||||
content, file_id=item.id, allow_sheet_structure_edit=item.allow_sheet_structure_edit,
|
|
||||||
protection_backup=None,
|
|
||||||
)
|
|
||||||
item.sheet_structure_protection_backup = backup
|
|
||||||
await collaboration.append_revision(db, item, content, source="IMPORT", created_by=user.id)
|
|
||||||
advance_generation(item)
|
|
||||||
await db.commit()
|
|
||||||
return await read_ledger(db, item, user)
|
|
||||||
|
|
||||||
|
|
||||||
async def restore_revision(db, item, revision_id, generation, user):
|
|
||||||
await ledger_access.require_access(db, item, user, manage=True)
|
|
||||||
check_generation(item, generation)
|
|
||||||
if item.status != "ACTIVE":
|
|
||||||
raise HTTPException(409, "请先启用台账再恢复版本")
|
|
||||||
return await collaboration.restore_revision(db, item, revision_id, user, None)
|
|
||||||
@@ -2,10 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import io
|
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -27,7 +25,7 @@ from app.models.collaboration import (
|
|||||||
)
|
)
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.collaboration import CollaborationCallbackPayload, CollaborationEditorConfigRead
|
from app.schemas.collaboration import CollaborationCallbackPayload, CollaborationEditorConfigRead
|
||||||
from app.services import collaboration_service, ledger_access, onlyoffice_service
|
from app.services import collaboration_service, onlyoffice_service
|
||||||
|
|
||||||
|
|
||||||
def collaboration_document_key(file_id: uuid.UUID, generation: int) -> str:
|
def collaboration_document_key(file_id: uuid.UUID, generation: int) -> str:
|
||||||
@@ -59,55 +57,12 @@ async def _active_session(
|
|||||||
).order_by(CollaborationSession.created_at.desc())
|
).order_by(CollaborationSession.created_at.desc())
|
||||||
)
|
)
|
||||||
if session:
|
if session:
|
||||||
should_recover = session.status == "ERROR"
|
if session.status != "ACTIVE":
|
||||||
if session.status == "ACTIVE":
|
session.status = "ACTIVE"
|
||||||
created_at = session.created_at
|
session.closed_at = None
|
||||||
if created_at.tzinfo is None:
|
await db.commit()
|
||||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
await db.refresh(session)
|
||||||
if (
|
return session
|
||||||
session.last_callback_at is None
|
|
||||||
and datetime.now(timezone.utc) - created_at < timedelta(seconds=15)
|
|
||||||
):
|
|
||||||
# The editor config can be requested twice before the first
|
|
||||||
# browser has connected and emitted status 1. Keep a short
|
|
||||||
# connection grace period so the second request does not retire
|
|
||||||
# the freshly issued key as a false stale session.
|
|
||||||
return session
|
|
||||||
live_users = await _document_server_users(session.document_key)
|
|
||||||
if live_users:
|
|
||||||
return session
|
|
||||||
# A service restart or rejected final callback can leave the row
|
|
||||||
# ACTIVE after Document Server has already retired the editing
|
|
||||||
# process. Reusing that key opens its forgotten copy as an
|
|
||||||
# unbound server backup, so retire it exactly like a final callback.
|
|
||||||
should_recover = True
|
|
||||||
|
|
||||||
# A final callback ends the editing lifecycle for this key. Never
|
|
||||||
# reactivate it: Document Server can retain a cached or forgotten copy
|
|
||||||
# for the old key, especially across a container restart.
|
|
||||||
recovered = (
|
|
||||||
await _recover_forgotten_content(session.document_key, item.file_type)
|
|
||||||
if should_recover
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if recovered is not None:
|
|
||||||
revision, _ = await collaboration_service.append_revision(
|
|
||||||
db,
|
|
||||||
item,
|
|
||||||
recovered,
|
|
||||||
source="SERVER_RECOVERY",
|
|
||||||
created_by=user_id,
|
|
||||||
change_summary="自动恢复在线文档服务器备份",
|
|
||||||
)
|
|
||||||
session.base_revision_id = revision.id
|
|
||||||
# The retired key can still point to Document Server's cache. A new
|
|
||||||
# generation must use a new key or a later open can fall back to the
|
|
||||||
# same server-side copy again.
|
|
||||||
item.generation += 1
|
|
||||||
session.status = "RECOVERED" if recovered is not None else "CLOSED"
|
|
||||||
session.closed_at = datetime.now(timezone.utc)
|
|
||||||
await db.commit()
|
|
||||||
session = None
|
|
||||||
if not item.current_revision_id:
|
if not item.current_revision_id:
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可编辑内容")
|
||||||
session = CollaborationSession(
|
session = CollaborationSession(
|
||||||
@@ -126,8 +81,6 @@ async def _active_session(
|
|||||||
async def build_editor_config(
|
async def build_editor_config(
|
||||||
db: AsyncSession, item: CollaborationFile, user
|
db: AsyncSession, item: CollaborationFile, user
|
||||||
) -> CollaborationEditorConfigRead:
|
) -> CollaborationEditorConfigRead:
|
||||||
if ledger_access.is_ledger(item):
|
|
||||||
await ledger_access.require_access(db, item, user)
|
|
||||||
await onlyoffice_service.ensure_onlyoffice_available()
|
await onlyoffice_service.ensure_onlyoffice_available()
|
||||||
revision = await db.get(CollaborationRevision, item.current_revision_id)
|
revision = await db.get(CollaborationRevision, item.current_revision_id)
|
||||||
if not revision or not Path(revision.file_uri).exists():
|
if not revision or not Path(revision.file_uri).exists():
|
||||||
@@ -205,15 +158,6 @@ async def build_shared_editor_config(
|
|||||||
client_id: str,
|
client_id: str,
|
||||||
display_name: str,
|
display_name: str,
|
||||||
) -> CollaborationEditorConfigRead:
|
) -> CollaborationEditorConfigRead:
|
||||||
if ledger_access.is_ledger(item):
|
|
||||||
version = link.token_version
|
|
||||||
item = await db.scalar(select(CollaborationFile).where(
|
|
||||||
CollaborationFile.id == item.id,
|
|
||||||
).with_for_update().execution_options(populate_existing=True))
|
|
||||||
await db.refresh(link)
|
|
||||||
if (not item or item.status != "ACTIVE" or not link.enabled or link.token_version != version
|
|
||||||
or (link.expires_at and link.expires_at <= datetime.now(timezone.utc))):
|
|
||||||
raise HTTPException(404, "共享链接不存在或已失效")
|
|
||||||
await onlyoffice_service.ensure_onlyoffice_available()
|
await onlyoffice_service.ensure_onlyoffice_available()
|
||||||
revision = await db.get(CollaborationRevision, item.current_revision_id)
|
revision = await db.get(CollaborationRevision, item.current_revision_id)
|
||||||
if not revision or not Path(revision.file_uri).exists():
|
if not revision or not Path(revision.file_uri).exists():
|
||||||
@@ -225,10 +169,6 @@ async def build_shared_editor_config(
|
|||||||
if link.expires_at and link.expires_at < expires_at:
|
if link.expires_at and link.expires_at < expires_at:
|
||||||
expires_at = link.expires_at
|
expires_at = link.expires_at
|
||||||
external_user_id = f"share-{link.id.hex[:12]}-{client_id[:32]}"
|
external_user_id = f"share-{link.id.hex[:12]}-{client_id[:32]}"
|
||||||
if ledger_access.is_ledger(item):
|
|
||||||
known_users = json.loads(session.active_users or "[]")
|
|
||||||
session.active_users = json.dumps(list(dict.fromkeys([*known_users, external_user_id])))
|
|
||||||
await db.commit()
|
|
||||||
config: dict[str, Any] = {
|
config: dict[str, Any] = {
|
||||||
"type": "desktop",
|
"type": "desktop",
|
||||||
"documentType": item.file_type,
|
"documentType": item.file_type,
|
||||||
@@ -294,8 +234,6 @@ async def get_session_content(
|
|||||||
item = await db.get(CollaborationFile, session.file_id)
|
item = await db.get(CollaborationFile, session.file_id)
|
||||||
if not revision or not item or not Path(revision.file_uri).exists():
|
if not revision or not item or not Path(revision.file_uri).exists():
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件内容不存在")
|
||||||
if ledger_access.is_ledger(item) and session.generation != item.generation:
|
|
||||||
raise HTTPException(403, "台账会话已失效,请重新打开")
|
|
||||||
return revision, item
|
return revision, item
|
||||||
|
|
||||||
|
|
||||||
@@ -384,123 +322,11 @@ async def _download_result(url: str) -> bytes:
|
|||||||
return bytes(content)
|
return bytes(content)
|
||||||
|
|
||||||
|
|
||||||
def _validate_recovered_content(content: bytes, file_type: str) -> None:
|
|
||||||
required_part = {
|
|
||||||
"word": "word/document.xml",
|
|
||||||
"cell": "xl/workbook.xml",
|
|
||||||
"slide": "ppt/presentation.xml",
|
|
||||||
}.get(file_type)
|
|
||||||
if not required_part or not zipfile.is_zipfile(io.BytesIO(content)):
|
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="在线文档服务器备份格式无效")
|
|
||||||
try:
|
|
||||||
with zipfile.ZipFile(io.BytesIO(content)) as package:
|
|
||||||
if required_part not in package.namelist() or package.testzip() is not None:
|
|
||||||
raise ValueError
|
|
||||||
except (zipfile.BadZipFile, ValueError) as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="在线文档服务器备份已损坏") from exc
|
|
||||||
|
|
||||||
|
|
||||||
async def _recover_forgotten_content(document_key: str, file_type: str) -> bytes | None:
|
|
||||||
"""Download a Document Server backup left behind by a failed final save."""
|
|
||||||
command = {"c": "getForgotten", "key": document_key}
|
|
||||||
token = jwt.encode(command, settings.ONLYOFFICE_JWT_SECRET or "", algorithm="HS256")
|
|
||||||
command_url = f"{settings.ONLYOFFICE_INTERNAL_URL.rstrip('/')}/command"
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as client:
|
|
||||||
response = await client.post(
|
|
||||||
command_url,
|
|
||||||
params={"shardkey": document_key},
|
|
||||||
json={**command, "token": token},
|
|
||||||
)
|
|
||||||
if response.status_code != status.HTTP_200_OK:
|
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="在线文档备份恢复服务暂不可用")
|
|
||||||
payload = response.json()
|
|
||||||
except (httpx.HTTPError, ValueError) as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="在线文档备份恢复服务暂不可用") from exc
|
|
||||||
|
|
||||||
error = payload.get("error") if isinstance(payload, dict) else None
|
|
||||||
if error == 1:
|
|
||||||
# Document Server no longer has a forgotten copy. Starting from the
|
|
||||||
# last confirmed CTMS revision is then the only recoverable state.
|
|
||||||
return None
|
|
||||||
if (
|
|
||||||
error != 0
|
|
||||||
or payload.get("key") != document_key
|
|
||||||
or not isinstance(payload.get("url"), str)
|
|
||||||
or not payload["url"]
|
|
||||||
):
|
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="在线文档服务器返回的备份信息无效")
|
|
||||||
content = await _download_result(payload["url"])
|
|
||||||
_validate_recovered_content(content, file_type)
|
|
||||||
return content
|
|
||||||
|
|
||||||
|
|
||||||
async def _document_server_users(document_key: str) -> list[str]:
|
|
||||||
"""Return live editor ids for a key without trusting stale database state."""
|
|
||||||
command = {"c": "info", "key": document_key}
|
|
||||||
token = jwt.encode(command, settings.ONLYOFFICE_JWT_SECRET or "", algorithm="HS256")
|
|
||||||
command_url = f"{settings.ONLYOFFICE_INTERNAL_URL.rstrip('/')}/command"
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as client:
|
|
||||||
response = await client.post(
|
|
||||||
command_url,
|
|
||||||
params={"shardkey": document_key},
|
|
||||||
json={**command, "token": token},
|
|
||||||
)
|
|
||||||
if response.status_code != status.HTTP_200_OK:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="在线文档会话检查服务暂不可用",
|
|
||||||
)
|
|
||||||
payload = response.json()
|
|
||||||
except (httpx.HTTPError, ValueError) as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
||||||
detail="在线文档会话检查服务暂不可用",
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
error = payload.get("error") if isinstance(payload, dict) else None
|
|
||||||
if error == 1:
|
|
||||||
# The database can retain an ACTIVE row after an interrupted callback,
|
|
||||||
# while Document Server no longer has a live editing process for it.
|
|
||||||
return []
|
|
||||||
users = payload.get("users") if isinstance(payload, dict) else None
|
|
||||||
if (
|
|
||||||
error != 0
|
|
||||||
or payload.get("key") != document_key
|
|
||||||
or not isinstance(users, list)
|
|
||||||
or any(not isinstance(user_id, str) or not user_id for user_id in users)
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
||||||
detail="在线文档服务器返回的会话信息无效",
|
|
||||||
)
|
|
||||||
return list(dict.fromkeys(users))
|
|
||||||
|
|
||||||
|
|
||||||
async def list_live_editing_sessions(db: AsyncSession) -> list[tuple[str, int]]:
|
|
||||||
"""List file titles and live editor counts for deployment safety checks."""
|
|
||||||
rows = (
|
|
||||||
await db.execute(
|
|
||||||
select(CollaborationSession.document_key, CollaborationFile.title)
|
|
||||||
.join(CollaborationFile, CollaborationFile.id == CollaborationSession.file_id)
|
|
||||||
.where(CollaborationSession.status == "ACTIVE")
|
|
||||||
.order_by(CollaborationFile.title)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
active: list[tuple[str, int]] = []
|
|
||||||
for document_key, title in rows:
|
|
||||||
users = await _document_server_users(document_key)
|
|
||||||
if users:
|
|
||||||
active.append((title, len(users)))
|
|
||||||
return active
|
|
||||||
|
|
||||||
|
|
||||||
async def _callback_user(
|
async def _callback_user(
|
||||||
db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession
|
db: AsyncSession, payload: CollaborationCallbackPayload, session: CollaborationSession
|
||||||
) -> User | None:
|
) -> User | None:
|
||||||
has_public_share_user = False
|
has_public_share_user = False
|
||||||
for value in payload.users or json.loads(session.active_users or "[]"):
|
for value in payload.users:
|
||||||
if value.startswith("share-"):
|
if value.startswith("share-"):
|
||||||
has_public_share_user = True
|
has_public_share_user = True
|
||||||
continue
|
continue
|
||||||
@@ -518,40 +344,13 @@ async def _callback_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def _ledger_callback_can_edit(db, item, payload, session):
|
|
||||||
users = payload.users or json.loads(session.active_users or "[]")
|
|
||||||
if not users:
|
|
||||||
return await ledger_access.can_edit(db, item, await _callback_user(db, payload, session))
|
|
||||||
for value in users:
|
|
||||||
try:
|
|
||||||
actor = await db.get(User, uuid.UUID(value))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
continue
|
|
||||||
if await ledger_access.can_edit(db, item, actor):
|
|
||||||
return True
|
|
||||||
link = await db.scalar(select(CollaborationShareLink).where(
|
|
||||||
CollaborationShareLink.file_id == item.id,
|
|
||||||
CollaborationShareLink.enabled.is_(True),
|
|
||||||
CollaborationShareLink.access_mode == "EDIT",
|
|
||||||
))
|
|
||||||
if not link or (link.expires_at and link.expires_at <= datetime.now(timezone.utc)):
|
|
||||||
return False
|
|
||||||
prefix = f"share-{link.id.hex[:12]}-"
|
|
||||||
return any(value.startswith(prefix) for value in users)
|
|
||||||
|
|
||||||
|
|
||||||
async def process_callback(
|
async def process_callback(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
session_id: uuid.UUID,
|
session_id: uuid.UUID,
|
||||||
payload: CollaborationCallbackPayload,
|
payload: CollaborationCallbackPayload,
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
# Lock the file before writing sessions, matching editor initialization and
|
|
||||||
# ledger permission changes. This also serializes callbacks across generations.
|
|
||||||
session = await db.scalar(
|
session = await db.scalar(
|
||||||
select(CollaborationSession)
|
select(CollaborationSession).where(CollaborationSession.id == session_id).with_for_update()
|
||||||
.join(CollaborationFile, CollaborationFile.id == CollaborationSession.file_id)
|
|
||||||
.where(CollaborationSession.id == session_id)
|
|
||||||
.with_for_update(of=CollaborationFile)
|
|
||||||
)
|
)
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作会话不存在")
|
||||||
@@ -568,12 +367,8 @@ async def process_callback(
|
|||||||
item = await db.get(CollaborationFile, session.file_id)
|
item = await db.get(CollaborationFile, session.file_id)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件不存在")
|
||||||
if ledger_access.is_ledger(item):
|
|
||||||
# Same lock as grant/settings changes: a stale callback cannot race a revocation.
|
|
||||||
item = await db.scalar(select(CollaborationFile).where(
|
|
||||||
CollaborationFile.id == item.id,
|
|
||||||
).with_for_update().execution_options(populate_existing=True))
|
|
||||||
session.last_callback_at = datetime.now(timezone.utc)
|
session.last_callback_at = datetime.now(timezone.utc)
|
||||||
|
session.active_users = json.dumps(payload.users, ensure_ascii=True)
|
||||||
result = "ACKNOWLEDGED"
|
result = "ACKNOWLEDGED"
|
||||||
saved_revision_id = None
|
saved_revision_id = None
|
||||||
|
|
||||||
@@ -582,10 +377,6 @@ async def process_callback(
|
|||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存回调缺少文件地址")
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="ONLYOFFICE 保存回调缺少文件地址")
|
||||||
if session.generation != item.generation:
|
if session.generation != item.generation:
|
||||||
result = "STALE"
|
result = "STALE"
|
||||||
elif ledger_access.is_ledger(item) and (
|
|
||||||
item.status != "ACTIVE" or not await _ledger_callback_can_edit(db, item, payload, session)
|
|
||||||
):
|
|
||||||
result = "ACCESS_REVOKED"
|
|
||||||
else:
|
else:
|
||||||
content = await _download_result(payload.url)
|
content = await _download_result(payload.url)
|
||||||
actor = await _callback_user(db, payload, session)
|
actor = await _callback_user(db, payload, session)
|
||||||
@@ -609,16 +400,10 @@ async def process_callback(
|
|||||||
session.status = "CLOSED"
|
session.status = "CLOSED"
|
||||||
session.closed_at = datetime.now(timezone.utc)
|
session.closed_at = datetime.now(timezone.utc)
|
||||||
result = "UNCHANGED"
|
result = "UNCHANGED"
|
||||||
elif payload.status == 3:
|
elif payload.status in {3, 7}:
|
||||||
session.status = "ERROR"
|
session.status = "ERROR"
|
||||||
result = "ERROR"
|
result = "ERROR"
|
||||||
elif payload.status == 7:
|
|
||||||
# A force-save error does not close the live co-editing session. The
|
|
||||||
# final status 2 callback can still persist the document normally.
|
|
||||||
result = "ERROR"
|
|
||||||
|
|
||||||
if payload.users or not ledger_access.is_ledger(item):
|
|
||||||
session.active_users = json.dumps(payload.users, ensure_ascii=True)
|
|
||||||
db.add(CollaborationCallbackReceipt(
|
db.add(CollaborationCallbackReceipt(
|
||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
fingerprint=fingerprint,
|
fingerprint=fingerprint,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,44 +0,0 @@
|
|||||||
"""Abort a deployment when ONLYOFFICE still has live collaborative editors."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
sys.path.insert(0, str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
from fastapi import HTTPException # noqa: E402
|
|
||||||
|
|
||||||
from app.core.config import settings # noqa: E402
|
|
||||||
from app.db.session import SessionLocal # noqa: E402
|
|
||||||
from app.services.onlyoffice_collaboration_service import list_live_editing_sessions # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
async def async_main() -> int:
|
|
||||||
if not settings.ONLYOFFICE_ENABLED:
|
|
||||||
print("ONLYOFFICE 未启用,跳过在线编辑会话检查")
|
|
||||||
return 0
|
|
||||||
try:
|
|
||||||
async with SessionLocal() as db:
|
|
||||||
active = await list_live_editing_sessions(db)
|
|
||||||
except HTTPException as exc:
|
|
||||||
print(f"无法确认 ONLYOFFICE 在线编辑状态:{exc.detail}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if not active:
|
|
||||||
print("未检测到 ONLYOFFICE 在线编辑者")
|
|
||||||
return 0
|
|
||||||
print("检测到仍在进行的 ONLYOFFICE 在线编辑,会中止本次部署:", file=sys.stderr)
|
|
||||||
for title, count in active:
|
|
||||||
print(f"- {title}:{count} 人在线", file=sys.stderr)
|
|
||||||
print("请通知用户退出编辑器,等待最终保存完成后重新执行部署。", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
raise SystemExit(asyncio.run(async_main()))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -4,7 +4,7 @@ import uuid
|
|||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import ANY, AsyncMock, call
|
from unittest.mock import ANY, AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -304,18 +304,6 @@ def test_result_download_url_is_restricted_and_public_proxy_urls_are_rewritten(m
|
|||||||
assert onlyoffice_collaboration_service._validate_result_url(
|
assert onlyoffice_collaboration_service._validate_result_url(
|
||||||
"http://localhost:8888/onlyoffice/cache/result.docx?token=signed"
|
"http://localhost:8888/onlyoffice/cache/result.docx?token=signed"
|
||||||
) == "http://onlyoffice/cache/result.docx?token=signed"
|
) == "http://onlyoffice/cache/result.docx?token=signed"
|
||||||
with pytest.raises(HTTPException) as mismatched_origin:
|
|
||||||
onlyoffice_collaboration_service._validate_result_url(
|
|
||||||
"https://ctms.example.com/onlyoffice/cache/result.xlsx?token=signed"
|
|
||||||
)
|
|
||||||
assert mismatched_origin.value.status_code == 422
|
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "FRONTEND_PUBLIC_URL", "https://ctms.example.com")
|
|
||||||
assert onlyoffice_collaboration_service._validate_result_url(
|
|
||||||
"https://ctms.example.com/onlyoffice/cache/result.xlsx?token=signed"
|
|
||||||
) == "http://onlyoffice/cache/result.xlsx?token=signed"
|
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "FRONTEND_PUBLIC_URL", "http://localhost:8888")
|
|
||||||
for value in (
|
for value in (
|
||||||
"http://backend:8000/internal/file",
|
"http://backend:8000/internal/file",
|
||||||
"http://onlyoffice.evil.example/cache/result.docx",
|
"http://onlyoffice.evil.example/cache/result.docx",
|
||||||
@@ -328,157 +316,6 @@ def test_result_download_url_is_restricted_and_public_proxy_urls_are_rewritten(m
|
|||||||
onlyoffice_collaboration_service._validate_result_url(value)
|
onlyoffice_collaboration_service._validate_result_url(value)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_forgotten_document_command_downloads_and_validates_server_backup(monkeypatch):
|
|
||||||
key = "ctms-collab-forgotten-key"
|
|
||||||
recovered = collaboration_service.blank_file_bytes("cell")
|
|
||||||
request = {}
|
|
||||||
|
|
||||||
class FakeResponse:
|
|
||||||
status_code = 200
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def json():
|
|
||||||
return {"error": 0, "key": key, "url": "http://onlyoffice/cache/forgotten.xlsx"}
|
|
||||||
|
|
||||||
class FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *_args):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def post(self, url, *, params, json):
|
|
||||||
request.update(url=url, params=params, body=json)
|
|
||||||
return FakeResponse()
|
|
||||||
|
|
||||||
monkeypatch.setattr(onlyoffice_collaboration_service.httpx, "AsyncClient", lambda **_kwargs: FakeClient())
|
|
||||||
download = AsyncMock(return_value=recovered)
|
|
||||||
monkeypatch.setattr(onlyoffice_collaboration_service, "_download_result", download)
|
|
||||||
|
|
||||||
result = await onlyoffice_collaboration_service._recover_forgotten_content(key, "cell")
|
|
||||||
|
|
||||||
assert result == recovered
|
|
||||||
assert request["url"] == "http://onlyoffice/command"
|
|
||||||
assert request["params"] == {"shardkey": key}
|
|
||||||
assert jwt.decode(
|
|
||||||
request["body"]["token"], settings.ONLYOFFICE_JWT_SECRET, algorithms=["HS256"]
|
|
||||||
) == {"c": "getForgotten", "key": key}
|
|
||||||
download.assert_awaited_once_with("http://onlyoffice/cache/forgotten.xlsx")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_forgotten_document_command_rejects_a_damaged_backup(monkeypatch):
|
|
||||||
key = "ctms-collab-damaged-key"
|
|
||||||
|
|
||||||
class FakeResponse:
|
|
||||||
status_code = 200
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def json():
|
|
||||||
return {"error": 0, "key": key, "url": "http://onlyoffice/cache/damaged.xlsx"}
|
|
||||||
|
|
||||||
class FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *_args):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def post(self, *_args, **_kwargs):
|
|
||||||
return FakeResponse()
|
|
||||||
|
|
||||||
monkeypatch.setattr(onlyoffice_collaboration_service.httpx, "AsyncClient", lambda **_kwargs: FakeClient())
|
|
||||||
monkeypatch.setattr(onlyoffice_collaboration_service, "_download_result", AsyncMock(return_value=b"broken"))
|
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as error:
|
|
||||||
await onlyoffice_collaboration_service._recover_forgotten_content(key, "cell")
|
|
||||||
assert error.value.status_code == 502
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_document_server_info_command_returns_unique_live_users(monkeypatch):
|
|
||||||
key = "ctms-collab-live-key"
|
|
||||||
request = {}
|
|
||||||
|
|
||||||
class FakeResponse:
|
|
||||||
status_code = 200
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def json():
|
|
||||||
return {"error": 0, "key": key, "users": ["user-1", "user-1", "user-2"]}
|
|
||||||
|
|
||||||
class FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *_args):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def post(self, url, *, params, json):
|
|
||||||
request.update(url=url, params=params, body=json)
|
|
||||||
return FakeResponse()
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
onlyoffice_collaboration_service.httpx,
|
|
||||||
"AsyncClient",
|
|
||||||
lambda **_kwargs: FakeClient(),
|
|
||||||
)
|
|
||||||
|
|
||||||
users = await onlyoffice_collaboration_service._document_server_users(key)
|
|
||||||
|
|
||||||
assert users == ["user-1", "user-2"]
|
|
||||||
assert request["url"] == "http://onlyoffice/command"
|
|
||||||
assert request["params"] == {"shardkey": key}
|
|
||||||
assert jwt.decode(
|
|
||||||
request["body"]["token"], settings.ONLYOFFICE_JWT_SECRET, algorithms=["HS256"]
|
|
||||||
) == {"c": "info", "key": key}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_document_server_info_command_treats_unknown_key_as_no_live_users(monkeypatch):
|
|
||||||
class FakeResponse:
|
|
||||||
status_code = 200
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def json():
|
|
||||||
return {"error": 1}
|
|
||||||
|
|
||||||
class FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *_args):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def post(self, *_args, **_kwargs):
|
|
||||||
return FakeResponse()
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
onlyoffice_collaboration_service.httpx,
|
|
||||||
"AsyncClient",
|
|
||||||
lambda **_kwargs: FakeClient(),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await onlyoffice_collaboration_service._document_server_users("retired-key") == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_live_editing_session_check_filters_stale_active_rows(monkeypatch):
|
|
||||||
rows = SimpleNamespace(all=lambda: [
|
|
||||||
("live-key", "正在编辑.xlsx"),
|
|
||||||
("stale-key", "陈旧记录.xlsx"),
|
|
||||||
])
|
|
||||||
db = SimpleNamespace(execute=AsyncMock(return_value=rows))
|
|
||||||
lookup = AsyncMock(side_effect=[["user-1", "user-2"], []])
|
|
||||||
monkeypatch.setattr(onlyoffice_collaboration_service, "_document_server_users", lookup)
|
|
||||||
|
|
||||||
active = await onlyoffice_collaboration_service.list_live_editing_sessions(db)
|
|
||||||
|
|
||||||
assert active == [("正在编辑.xlsx", 2)]
|
|
||||||
assert lookup.await_args_list == [call("live-key"), call("stale-key")]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_editor_config_grants_edit_only_after_collaboration_permission(monkeypatch, tmp_path):
|
async def test_editor_config_grants_edit_only_after_collaboration_permission(monkeypatch, tmp_path):
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
@@ -1179,31 +1016,6 @@ async def test_callback_error_status_is_recorded_and_acknowledged():
|
|||||||
assert db.added[0].result == "ERROR"
|
assert db.added[0].result == "ERROR"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_force_save_error_keeps_the_live_session_active():
|
|
||||||
session = SimpleNamespace(
|
|
||||||
id=uuid.uuid4(),
|
|
||||||
file_id=uuid.uuid4(),
|
|
||||||
document_key="ctms-collab-force-save-error-key",
|
|
||||||
generation=1,
|
|
||||||
status="ACTIVE",
|
|
||||||
active_users=None,
|
|
||||||
last_callback_at=None,
|
|
||||||
)
|
|
||||||
item = SimpleNamespace(id=session.file_id, generation=1)
|
|
||||||
db = _CallbackDb(session, item)
|
|
||||||
|
|
||||||
result = await onlyoffice_collaboration_service.process_callback(
|
|
||||||
db,
|
|
||||||
session.id,
|
|
||||||
CollaborationCallbackPayload(key=session.document_key, status=7),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == {"error": 0}
|
|
||||||
assert session.status == "ACTIVE"
|
|
||||||
assert db.added[0].result == "ERROR"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_force_save_updates_session_recovery_revision(monkeypatch):
|
async def test_force_save_updates_session_recovery_revision(monkeypatch):
|
||||||
session = SimpleNamespace(
|
session = SimpleNamespace(
|
||||||
|
|||||||
@@ -1,569 +0,0 @@
|
|||||||
import io
|
|
||||||
import uuid
|
|
||||||
import zipfile
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
import pytest_asyncio
|
|
||||||
from fastapi import FastAPI, HTTPException, UploadFile
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.types import DateTime, TypeDecorator
|
|
||||||
|
|
||||||
from conftest import _create_test_engine
|
|
||||||
from app.api.v1.ledgers import router
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.deps import get_current_user, get_db_session
|
|
||||||
from app.models.collaboration import CollaborationCallbackReceipt, CollaborationFile, CollaborationRevision, CollaborationSession, CollaborationShareLink
|
|
||||||
from app.models.user import User, UserStatus
|
|
||||||
from app.schemas.collaboration import CollaborationCallbackPayload
|
|
||||||
from app.schemas.ledger import LedgerMemberUpsert, LedgerSettings
|
|
||||||
from app.services import collaboration_service as collaboration
|
|
||||||
from app.services import ledger_service as ledgers, onlyoffice_collaboration_service as office, onlyoffice_service
|
|
||||||
|
|
||||||
|
|
||||||
class SQLiteUTCDateTime(TypeDecorator):
|
|
||||||
"""Preserve the PostgreSQL UTC timestamp contract in the SQLite fixture."""
|
|
||||||
impl = DateTime(timezone=True)
|
|
||||||
cache_ok = True
|
|
||||||
|
|
||||||
def process_result_value(self, value, dialect):
|
|
||||||
return value.replace(tzinfo=timezone.utc) if value and value.tzinfo is None else value
|
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
|
||||||
async def env(monkeypatch, tmp_path):
|
|
||||||
for column in CollaborationShareLink.__table__.columns:
|
|
||||||
if isinstance(column.type, DateTime) and column.type.timezone:
|
|
||||||
monkeypatch.setattr(column, "type", SQLiteUTCDateTime())
|
|
||||||
engine = await _create_test_engine()
|
|
||||||
monkeypatch.setattr(collaboration, "COLLABORATION_ROOT", tmp_path)
|
|
||||||
monkeypatch.setattr(settings, "ONLYOFFICE_JWT_SECRET", "ledger-test-secret-long-enough-for-tests")
|
|
||||||
monkeypatch.setattr(onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
|
|
||||||
monkeypatch.setattr(office, "_document_server_users", AsyncMock(return_value=["live-user"]))
|
|
||||||
async with AsyncSession(engine, expire_on_commit=False) as db:
|
|
||||||
users = [User(id=uuid.uuid4(), email=f"ledger-{i}@example.com", password_hash="hash",
|
|
||||||
full_name=f"Ledger user {i}", clinical_department="test", is_admin=i == 0,
|
|
||||||
status=UserStatus.ACTIVE) for i in range(4)]
|
|
||||||
db.add_all(users)
|
|
||||||
await db.commit()
|
|
||||||
initial = await ledgers.initialize_ledgers(db, users[0])
|
|
||||||
app = FastAPI()
|
|
||||||
app.include_router(router, prefix="/api/v1/ledgers")
|
|
||||||
identity = SimpleNamespace(user=users[0])
|
|
||||||
app.dependency_overrides[get_current_user] = lambda: identity.user
|
|
||||||
app.dependency_overrides[get_db_session] = lambda: db
|
|
||||||
async with httpx.AsyncClient(app=app, base_url="http://test") as client:
|
|
||||||
yield SimpleNamespace(db=db, admin=users[0], editor=users[1], viewer=users[2], outsider=users[3],
|
|
||||||
initial=initial, identity=identity, client=client, app=app)
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def grant(env, user, role, index=0):
|
|
||||||
item = await ledgers.get_ledger(env.db, env.initial[index].id, env.admin, manage=True, lock=True)
|
|
||||||
await ledgers.set_member(env.db, item, LedgerMemberUpsert(user_id=user.id, role=role), env.admin)
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_initialization_preserves_both_uploaded_templates_and_is_idempotent(env):
|
|
||||||
repeated = await ledgers.initialize_ledgers(env.db, env.admin)
|
|
||||||
assert [r.id for r in repeated] == [r.id for r in env.initial]
|
|
||||||
for result, (_, title, filename, _) in zip(repeated, ledgers.LEDGER_TEMPLATES):
|
|
||||||
item = await env.db.get(CollaborationFile, result.id)
|
|
||||||
assert item.study_id is None and item.folder_id is None and item.scope == "LEDGER"
|
|
||||||
assert item.title == title
|
|
||||||
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
|
|
||||||
with zipfile.ZipFile(ledgers.TEMPLATE_ROOT / filename) as original, zipfile.ZipFile(revision.file_uri) as saved:
|
|
||||||
assert original.namelist() == saved.namelist()
|
|
||||||
for part in original.namelist():
|
|
||||||
if part != "xl/workbook.xml":
|
|
||||||
assert original.read(part) == saved.read(part)
|
|
||||||
assert b'lockStructure="1"' in saved.read("xl/workbook.xml")
|
|
||||||
assert "ledgers" in Path(revision.file_uri).parts
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_failed_ledger_session_recovers_server_backup_under_a_new_document_key(env, monkeypatch):
|
|
||||||
item = await env.db.get(CollaborationFile, env.initial[0].id)
|
|
||||||
first = await office.build_editor_config(env.db, item, env.admin)
|
|
||||||
failed = await env.db.scalar(select(CollaborationSession).where(
|
|
||||||
CollaborationSession.file_id == item.id,
|
|
||||||
CollaborationSession.generation == item.generation,
|
|
||||||
))
|
|
||||||
failed.status = "ERROR"
|
|
||||||
await env.db.commit()
|
|
||||||
|
|
||||||
current = await env.db.get(CollaborationRevision, item.current_revision_id)
|
|
||||||
recovered_buffer = io.BytesIO(Path(current.file_uri).read_bytes())
|
|
||||||
with zipfile.ZipFile(recovered_buffer, "a") as package:
|
|
||||||
package.comment = b"document-server-recovery"
|
|
||||||
recovery = AsyncMock(return_value=recovered_buffer.getvalue())
|
|
||||||
monkeypatch.setattr(office, "_recover_forgotten_content", recovery)
|
|
||||||
|
|
||||||
reopened = await office.build_editor_config(env.db, item, env.admin)
|
|
||||||
await env.db.refresh(item)
|
|
||||||
await env.db.refresh(failed)
|
|
||||||
sessions = (await env.db.scalars(select(CollaborationSession).where(
|
|
||||||
CollaborationSession.file_id == item.id,
|
|
||||||
).order_by(CollaborationSession.generation))).all()
|
|
||||||
recovered_revision = await env.db.get(CollaborationRevision, item.current_revision_id)
|
|
||||||
|
|
||||||
assert first.config["document"]["key"] != reopened.config["document"]["key"]
|
|
||||||
assert item.generation == 2
|
|
||||||
assert failed.status == "RECOVERED"
|
|
||||||
assert [session.generation for session in sessions] == [1, 2]
|
|
||||||
assert recovered_revision.source == "SERVER_RECOVERY"
|
|
||||||
assert recovered_revision.change_summary == "自动恢复在线文档服务器备份"
|
|
||||||
recovery.assert_awaited_once_with(failed.document_key, "cell")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_closed_ledger_session_starts_a_new_key_instead_of_reopening_server_cache(env, monkeypatch):
|
|
||||||
item = await env.db.get(CollaborationFile, env.initial[0].id)
|
|
||||||
first = await office.build_editor_config(env.db, item, env.admin)
|
|
||||||
closed = await env.db.scalar(select(CollaborationSession).where(
|
|
||||||
CollaborationSession.file_id == item.id,
|
|
||||||
CollaborationSession.generation == item.generation,
|
|
||||||
))
|
|
||||||
await office.process_callback(env.db, closed.id, CollaborationCallbackPayload(
|
|
||||||
key=closed.document_key,
|
|
||||||
status=4,
|
|
||||||
))
|
|
||||||
recovery = AsyncMock(return_value=None)
|
|
||||||
monkeypatch.setattr(office, "_recover_forgotten_content", recovery)
|
|
||||||
|
|
||||||
reopened = await office.build_editor_config(env.db, item, env.admin)
|
|
||||||
await env.db.refresh(item)
|
|
||||||
await env.db.refresh(closed)
|
|
||||||
sessions = (await env.db.scalars(select(CollaborationSession).where(
|
|
||||||
CollaborationSession.file_id == item.id,
|
|
||||||
).order_by(CollaborationSession.generation))).all()
|
|
||||||
|
|
||||||
assert first.config["document"]["key"] != reopened.config["document"]["key"]
|
|
||||||
assert item.generation == 2
|
|
||||||
assert closed.status == "CLOSED"
|
|
||||||
assert [session.generation for session in sessions] == [1, 2]
|
|
||||||
recovery.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stale_active_ledger_session_starts_a_new_key_when_document_server_has_no_users(
|
|
||||||
env, monkeypatch
|
|
||||||
):
|
|
||||||
item = await env.db.get(CollaborationFile, env.initial[0].id)
|
|
||||||
first = await office.build_editor_config(env.db, item, env.admin)
|
|
||||||
stale = await env.db.scalar(select(CollaborationSession).where(
|
|
||||||
CollaborationSession.file_id == item.id,
|
|
||||||
CollaborationSession.generation == item.generation,
|
|
||||||
))
|
|
||||||
stale.last_callback_at = datetime.now(timezone.utc)
|
|
||||||
await env.db.commit()
|
|
||||||
live_users = AsyncMock(return_value=[])
|
|
||||||
recovery = AsyncMock(return_value=None)
|
|
||||||
monkeypatch.setattr(office, "_document_server_users", live_users)
|
|
||||||
monkeypatch.setattr(office, "_recover_forgotten_content", recovery)
|
|
||||||
|
|
||||||
reopened = await office.build_editor_config(env.db, item, env.admin)
|
|
||||||
await env.db.refresh(item)
|
|
||||||
await env.db.refresh(stale)
|
|
||||||
|
|
||||||
assert first.config["document"]["key"] != reopened.config["document"]["key"]
|
|
||||||
assert item.generation == 2
|
|
||||||
assert stale.status == "CLOSED"
|
|
||||||
live_users.assert_awaited_once_with(stale.document_key)
|
|
||||||
recovery.assert_awaited_once_with(stale.document_key, "cell")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_new_active_session_is_reused_during_browser_connection_grace(env, monkeypatch):
|
|
||||||
item = await env.db.get(CollaborationFile, env.initial[0].id)
|
|
||||||
first = await office.build_editor_config(env.db, item, env.admin)
|
|
||||||
live_users = AsyncMock(return_value=[])
|
|
||||||
monkeypatch.setattr(office, "_document_server_users", live_users)
|
|
||||||
|
|
||||||
repeated = await office.build_editor_config(env.db, item, env.admin)
|
|
||||||
|
|
||||||
assert first.config["document"]["key"] == repeated.config["document"]["key"]
|
|
||||||
live_users.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_account_grants_are_independent_and_all_file_routes_require_access(env):
|
|
||||||
await grant(env, env.editor, "EDITOR")
|
|
||||||
env.identity.user = env.editor
|
|
||||||
response = await env.client.get("/api/v1/ledgers")
|
|
||||||
assert response.status_code == 200 and response.headers["cache-control"] == "no-store"
|
|
||||||
assert [row["id"] for row in response.json()] == [str(env.initial[0].id)]
|
|
||||||
assert response.json()[0]["can_edit"] is True
|
|
||||||
other = env.initial[1].id
|
|
||||||
for suffix in ["", "/editor-config", "/revisions", "/members", "/candidates"]:
|
|
||||||
assert (await env.client.get(f"/api/v1/ledgers/{other}{suffix}")).status_code == 404
|
|
||||||
assert (await env.client.post("/api/v1/ledgers/initialize")).status_code == 403
|
|
||||||
env.identity.user = env.outsider
|
|
||||||
assert (await env.client.get("/api/v1/ledgers")).json() == []
|
|
||||||
env.app.dependency_overrides.pop(get_current_user)
|
|
||||||
assert (await env.client.get("/api/v1/ledgers")).status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_editor_and_viewer_share_session_but_receive_distinct_signed_permissions(env):
|
|
||||||
item = await grant(env, env.editor, "EDITOR")
|
|
||||||
await grant(env, env.viewer, "VIEWER")
|
|
||||||
editor = await office.build_editor_config(env.db, item, env.editor)
|
|
||||||
viewer = await office.build_editor_config(env.db, item, env.viewer)
|
|
||||||
assert editor.config["document"]["key"] == viewer.config["document"]["key"]
|
|
||||||
assert editor.access_mode == "edit" and viewer.access_mode == "view"
|
|
||||||
assert editor.config["document"]["permissions"]["edit"] is True
|
|
||||||
assert viewer.config["document"]["permissions"]["edit"] is False
|
|
||||||
assert not editor.can_save_as and not editor.can_request_edit and not editor.can_download
|
|
||||||
env.identity.user = env.editor
|
|
||||||
assert (await env.client.get(f"/api/v1/ledgers/{item.id}/members")).status_code == 403
|
|
||||||
assert (await env.client.post(f"/api/v1/ledgers/{item.id}/downloads", json={"file_type": "xlsx"})).status_code == 403
|
|
||||||
with pytest.raises(HTTPException) as error:
|
|
||||||
await collaboration.restore_revision(env.db, item, item.current_revision_id, env.editor, None)
|
|
||||||
assert error.value.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_neither_editors_nor_admins_can_delete_ledger_or_revision(env):
|
|
||||||
item = await grant(env, env.editor, "EDITOR")
|
|
||||||
for user in [env.admin, env.editor]:
|
|
||||||
env.identity.user = user
|
|
||||||
assert (await env.client.delete(f"/api/v1/ledgers/{item.id}")).status_code == 405
|
|
||||||
with pytest.raises(HTTPException) as error:
|
|
||||||
await collaboration.move_to_trash(env.db, item, user)
|
|
||||||
assert error.value.status_code == 403
|
|
||||||
with pytest.raises(HTTPException) as error:
|
|
||||||
await collaboration.delete_revision(env.db, item, item.current_revision_id, user)
|
|
||||||
assert error.value.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_revocation_invalidates_old_callback_before_result_download(env, monkeypatch):
|
|
||||||
item = await grant(env, env.editor, "EDITOR")
|
|
||||||
config = await office.build_editor_config(env.db, item, env.editor)
|
|
||||||
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
|
|
||||||
baseline = item.current_revision_id
|
|
||||||
download = AsyncMock(return_value=b"should never be downloaded")
|
|
||||||
monkeypatch.setattr(office, "_download_result", download)
|
|
||||||
await ledgers.remove_member(env.db, item, env.editor.id, env.admin)
|
|
||||||
result = await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
|
|
||||||
key=config.config["document"]["key"], status=6, url="http://onlyoffice/result.xlsx", users=[str(env.editor.id)],
|
|
||||||
))
|
|
||||||
assert result == {"error": 0} and item.current_revision_id == baseline
|
|
||||||
download.assert_not_awaited()
|
|
||||||
receipt = await env.db.scalar(select(CollaborationCallbackReceipt).where(CollaborationCallbackReceipt.session_id == session.id))
|
|
||||||
assert receipt.result == "STALE"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_saved_callback_is_versioned_and_duplicate_is_idempotent(env, monkeypatch):
|
|
||||||
item = await grant(env, env.editor, "EDITOR")
|
|
||||||
config = await office.build_editor_config(env.db, item, env.editor)
|
|
||||||
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
|
|
||||||
monkeypatch.setattr(office, "_download_result", AsyncMock(return_value=collaboration.blank_file_bytes("cell")))
|
|
||||||
payload = CollaborationCallbackPayload(key=config.config["document"]["key"], status=6,
|
|
||||||
url="http://onlyoffice/result.xlsx", users=[str(env.editor.id)])
|
|
||||||
await office.process_callback(env.db, session.id, payload)
|
|
||||||
saved = item.current_revision_id
|
|
||||||
await office.process_callback(env.db, session.id, payload)
|
|
||||||
assert item.current_revision_id == saved
|
|
||||||
assert len(await collaboration.list_revisions(env.db, item)) == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_archive_disables_edit_and_stale_settings_are_rejected(env):
|
|
||||||
item = await grant(env, env.editor, "EDITOR")
|
|
||||||
payload = LedgerSettings(title=item.title, description="Archived", status="ARCHIVED", owner_id=item.owner_id,
|
|
||||||
allow_export=False, allow_sheet_structure_edit=False, generation=item.generation)
|
|
||||||
await ledgers.update_settings(env.db, item, payload, env.admin)
|
|
||||||
assert not (await ledgers.read_ledger(env.db, item, env.editor)).can_edit
|
|
||||||
assert (await office.build_editor_config(env.db, item, env.editor)).access_mode == "view"
|
|
||||||
with pytest.raises(HTTPException) as error:
|
|
||||||
await ledgers.update_settings(env.db, item, payload, env.admin)
|
|
||||||
assert error.value.status_code == 409
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_and_restore_keep_previous_versions_and_workbook_protection(env):
|
|
||||||
item = await ledgers.get_ledger(env.db, env.initial[0].id, env.admin, manage=True)
|
|
||||||
first = item.current_revision_id
|
|
||||||
upload = UploadFile(filename="import.xlsx", file=io.BytesIO(collaboration.blank_file_bytes("cell")))
|
|
||||||
await ledgers.import_content(env.db, item, upload, item.generation, env.admin)
|
|
||||||
second = item.current_revision_id
|
|
||||||
await ledgers.restore_revision(env.db, item, first, item.generation, env.admin)
|
|
||||||
assert item.current_revision_id not in {first, second}
|
|
||||||
assert len(await collaboration.list_revisions(env.db, item)) == 3
|
|
||||||
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
|
|
||||||
with zipfile.ZipFile(revision.file_uri) as package:
|
|
||||||
assert b'lockStructure="1"' in package.read("xl/workbook.xml")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_inactive_account_cannot_be_granted_or_save_existing_session(env, monkeypatch):
|
|
||||||
item = await grant(env, env.editor, "EDITOR")
|
|
||||||
await office.build_editor_config(env.db, item, env.editor)
|
|
||||||
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
|
|
||||||
env.editor.status = UserStatus.DISABLED
|
|
||||||
await env.db.commit()
|
|
||||||
with pytest.raises(HTTPException) as error:
|
|
||||||
await ledgers.set_member(env.db, item, LedgerMemberUpsert(user_id=env.editor.id, role="MANAGER"), env.admin)
|
|
||||||
assert error.value.status_code == 422
|
|
||||||
download = AsyncMock()
|
|
||||||
monkeypatch.setattr(office, "_download_result", download)
|
|
||||||
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
|
|
||||||
key=session.document_key, status=6, url="http://onlyoffice/result.xlsx", users=[str(env.editor.id)],
|
|
||||||
))
|
|
||||||
download.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_restore_respects_current_unlocked_workbook_policy(env):
|
|
||||||
item = await ledgers.get_ledger(env.db, env.initial[0].id, env.admin, manage=True)
|
|
||||||
initial = item.current_revision_id
|
|
||||||
payload = LedgerSettings(title=item.title, status="ACTIVE", owner_id=item.owner_id,
|
|
||||||
allow_sheet_structure_edit=True, generation=item.generation)
|
|
||||||
await ledgers.update_settings(env.db, item, payload, env.admin)
|
|
||||||
await ledgers.import_content(env.db, item, UploadFile(filename="blank.xlsx", file=io.BytesIO(
|
|
||||||
collaboration.blank_file_bytes("cell"))), item.generation, env.admin)
|
|
||||||
await ledgers.restore_revision(env.db, item, initial, item.generation, env.admin)
|
|
||||||
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
|
|
||||||
with zipfile.ZipFile(revision.file_uri) as package:
|
|
||||||
assert b"workbookProtection" not in package.read("xl/workbook.xml")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_shared_access_uses_global_candidates_and_protects_implicit_managers(env):
|
|
||||||
item = await grant(env, env.editor, "EDITOR")
|
|
||||||
base = f"/api/v1/ledgers/{item.id}"
|
|
||||||
members = (await env.client.get(f"{base}/members")).json()
|
|
||||||
owner = next(row for row in members if row["user_id"] == str(env.admin.id))
|
|
||||||
assert owner["protected_label"] == "所有者" and owner["role"] == "MANAGER"
|
|
||||||
assert (await env.client.delete(f"{base}/members/{env.admin.id}")).status_code == 422
|
|
||||||
candidates = (await env.client.get(f"{base}/candidates")).json()
|
|
||||||
assert {row["user_id"] for row in candidates} == {str(user.id) for user in [env.admin, env.editor, env.viewer, env.outsider]}
|
|
||||||
assert all(row["can_be_manager"] and row["can_be_editor"] for row in candidates)
|
|
||||||
original_title = item.title
|
|
||||||
result = await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_edit_request": False})
|
|
||||||
assert result.status_code == 200 and not result.json()["allow_edit_request"]
|
|
||||||
assert result.json()["title"] == original_title and not result.json()["allow_export"]
|
|
||||||
for actor, code in [(env.editor, 403), (env.outsider, 404)]:
|
|
||||||
env.identity.user = actor
|
|
||||||
for suffix in ["share-link", "edit-requests", "members", "candidates"]:
|
|
||||||
assert (await env.client.get(f"{base}/{suffix}")).status_code == code
|
|
||||||
assert (await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_export": True})).status_code == code
|
|
||||||
assert (await env.client.put(f"{base}/share-link", json={"enabled": True})).status_code == code
|
|
||||||
assert (await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.viewer.id)})).status_code == code
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_viewer_edit_request_is_deduplicated_and_approval_grants_editor(env):
|
|
||||||
item = await grant(env, env.viewer, "VIEWER")
|
|
||||||
base = f"/api/v1/ledgers/{item.id}"
|
|
||||||
assert (await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_edit_request": True})).status_code == 200
|
|
||||||
env.identity.user = env.viewer
|
|
||||||
assert (await env.client.get(base)).json()["can_request_edit"]
|
|
||||||
request = await env.client.post(f"{base}/edit-requests")
|
|
||||||
assert request.status_code == 200
|
|
||||||
request_id = request.json()["id"]
|
|
||||||
assert (await env.client.post(f"{base}/edit-requests")).json()["id"] == request_id
|
|
||||||
read = (await env.client.get(base)).json()
|
|
||||||
assert read["edit_request_status"] == "PENDING" and not read["can_request_edit"]
|
|
||||||
assert (await env.client.post(f"{base}/edit-requests/{request_id}/resolve", json={"status": "APPROVED"})).status_code == 403
|
|
||||||
generation = item.generation
|
|
||||||
env.identity.user = env.admin
|
|
||||||
assert len((await env.client.get(f"{base}/edit-requests")).json()) == 1
|
|
||||||
result = await env.client.post(f"{base}/edit-requests/{request_id}/resolve", json={"status": "APPROVED"})
|
|
||||||
assert result.status_code == 200 and result.json()["status"] == "APPROVED"
|
|
||||||
env.identity.user = env.viewer
|
|
||||||
read = (await env.client.get(base)).json()
|
|
||||||
assert read["role"] == "EDITOR" and read["can_edit"] and read["generation"] == generation + 1
|
|
||||||
assert not read["can_request_edit"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_revoked_requester_cannot_regain_access_through_pending_approval(env):
|
|
||||||
item = await grant(env, env.viewer, "VIEWER")
|
|
||||||
base = f"/api/v1/ledgers/{item.id}"
|
|
||||||
assert (await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_edit_request": True})).status_code == 200
|
|
||||||
env.identity.user = env.viewer
|
|
||||||
request_id = (await env.client.post(f"{base}/edit-requests")).json()["id"]
|
|
||||||
await ledgers.remove_member(env.db, item, env.viewer.id, env.admin)
|
|
||||||
env.identity.user = env.admin
|
|
||||||
assert (await env.client.post(f"{base}/edit-requests/{request_id}/resolve", json={"status": "APPROVED"})).status_code == 422
|
|
||||||
assert not await collaboration.can_edit_file(env.db, item, env.viewer)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ownership_transfer_accepts_system_accounts_and_keeps_previous_owner_access(env):
|
|
||||||
item = await grant(env, env.editor, "MANAGER")
|
|
||||||
base = f"/api/v1/ledgers/{item.id}"
|
|
||||||
env.identity.user = env.editor
|
|
||||||
assert (await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.viewer.id)})).status_code == 403
|
|
||||||
env.identity.user = env.admin
|
|
||||||
assert (await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.viewer.id)})).status_code == 200
|
|
||||||
env.identity.user = env.viewer
|
|
||||||
result = await env.client.post(f"{base}/transfer-ownership", json={"new_owner_id": str(env.outsider.id)})
|
|
||||||
assert result.status_code == 200 and result.json()["owner_id"] == str(env.outsider.id)
|
|
||||||
assert result.json()["can_manage"] and not result.json()["can_transfer_ownership"]
|
|
||||||
assert item.study_id is None
|
|
||||||
members = (await env.client.get(f"{base}/members")).json()
|
|
||||||
admin = next(row for row in members if row["user_id"] == str(env.admin.id))
|
|
||||||
assert admin["protected_label"] == "系统管理员"
|
|
||||||
assert (await env.client.delete(f"{base}/members/{env.admin.id}")).status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
async def shared_ledger(env, *, mode="EDIT", password=None):
|
|
||||||
from app.models.collaboration import CollaborationShareLink
|
|
||||||
item = await ledgers.get_ledger(env.db, env.initial[0].id, env.admin)
|
|
||||||
response = await env.client.put(f"/api/v1/ledgers/{item.id}/share-link", json={
|
|
||||||
"enabled": True, "access_mode": mode, "expiry_policy": "SEVEN_DAYS",
|
|
||||||
"password_mode": "SET" if password else "KEEP", **({"password": password} if password else {}),
|
|
||||||
})
|
|
||||||
assert response.status_code == 200
|
|
||||||
link = await env.db.get(CollaborationShareLink, uuid.UUID(response.json()["id"]))
|
|
||||||
return item, link, response.json()["share_token"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ledger_share_defaults_closed_and_password_change_revokes_old_credentials(env):
|
|
||||||
from app.services import collaboration_share_service as shares
|
|
||||||
base = f"/api/v1/ledgers/{env.initial[0].id}"
|
|
||||||
initial = (await env.client.get(f"{base}/share-link")).json()
|
|
||||||
assert not initial["enabled"] and initial["share_token"] is None
|
|
||||||
item, link, token = await shared_ledger(env, password="test-password")
|
|
||||||
grant_token = await shares.verify_share_password(env.db, token, "test-password")
|
|
||||||
shares.validate_access_grant(link, grant_token.access_token)
|
|
||||||
with pytest.raises(HTTPException) as wrong:
|
|
||||||
await shares.verify_share_password(env.db, token, "incorrect")
|
|
||||||
assert wrong.value.status_code == 401
|
|
||||||
generation = item.generation
|
|
||||||
await env.client.put(f"{base}/share-link", json={"enabled": True, "password_mode": "SET", "password": "new-password"})
|
|
||||||
assert item.generation == generation + 1
|
|
||||||
with pytest.raises(HTTPException):
|
|
||||||
await shares.resolve_active_share(env.db, token)
|
|
||||||
with pytest.raises(HTTPException):
|
|
||||||
shares.validate_access_grant(link, grant_token.access_token)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_anonymous_ledger_save_keeps_share_identity_and_close_blocks_stale_callback(env, monkeypatch):
|
|
||||||
item, link, _ = await shared_ledger(env)
|
|
||||||
config = await office.build_shared_editor_config(env.db, item, link, client_id="test-client", display_name="访客")
|
|
||||||
assert config.access_mode == "edit" and not config.can_save_as and not config.can_download
|
|
||||||
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
|
|
||||||
download = AsyncMock(return_value=collaboration.blank_file_bytes("cell"))
|
|
||||||
monkeypatch.setattr(office, "_download_result", download)
|
|
||||||
# ONLYOFFICE can send a final save with an empty user list. It must not become an owner save.
|
|
||||||
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
|
|
||||||
key=session.document_key, status=6, url="http://onlyoffice/first.xlsx", users=[],
|
|
||||||
))
|
|
||||||
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
|
|
||||||
assert revision.source == "SHARE_FORCE_SAVE" and revision.created_by is None
|
|
||||||
saved = item.current_revision_id
|
|
||||||
await env.client.put(f"/api/v1/ledgers/{item.id}/share-link", json={"enabled": False})
|
|
||||||
download.reset_mock()
|
|
||||||
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
|
|
||||||
key=session.document_key, status=2, url="http://onlyoffice/after-close.xlsx", users=[],
|
|
||||||
))
|
|
||||||
download.assert_not_awaited()
|
|
||||||
assert item.current_revision_id == saved
|
|
||||||
with pytest.raises(HTTPException):
|
|
||||||
await office.build_shared_editor_config(env.db, item, link, client_id="new-client", display_name="访客")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("mode,expired", [("VIEW", False), ("EDIT", True)])
|
|
||||||
async def test_anonymous_view_or_expired_link_cannot_save_even_without_callback_users(env, monkeypatch, mode, expired):
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
item, link, _ = await shared_ledger(env, mode=mode)
|
|
||||||
await office.build_shared_editor_config(env.db, item, link, client_id="test-client", display_name="访客")
|
|
||||||
session = await env.db.scalar(select(CollaborationSession).where(CollaborationSession.file_id == item.id))
|
|
||||||
if expired:
|
|
||||||
link.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
|
||||||
await env.db.commit()
|
|
||||||
saved = item.current_revision_id
|
|
||||||
download = AsyncMock()
|
|
||||||
monkeypatch.setattr(office, "_download_result", download)
|
|
||||||
await office.process_callback(env.db, session.id, CollaborationCallbackPayload(
|
|
||||||
key=session.document_key, status=6, url="http://onlyoffice/denied.xlsx", users=[],
|
|
||||||
))
|
|
||||||
download.assert_not_awaited()
|
|
||||||
assert item.current_revision_id == saved
|
|
||||||
receipt = await env.db.scalar(select(CollaborationCallbackReceipt).where(CollaborationCallbackReceipt.session_id == session.id))
|
|
||||||
assert receipt.result == "ACCESS_REVOKED"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ledger_uses_existing_public_share_http_flow_without_a_project(env):
|
|
||||||
from app.api.v1.collaboration import public_router
|
|
||||||
env.app.include_router(public_router, prefix="/api/v1/collaboration/shares")
|
|
||||||
item, _, token = await shared_ledger(env, password="share-password")
|
|
||||||
env.app.dependency_overrides.pop(get_current_user)
|
|
||||||
headers = {"X-CTMS-Share-Token": token}
|
|
||||||
base = "/api/v1/collaboration/shares"
|
|
||||||
metadata = await env.client.get(f"{base}/metadata", headers=headers)
|
|
||||||
assert metadata.status_code == 200 and metadata.json()["requires_password"]
|
|
||||||
body = {"client_id": "test-client", "display_name": "访客"}
|
|
||||||
assert (await env.client.post(f"{base}/editor-config", headers=headers, json=body)).status_code == 401
|
|
||||||
access = await env.client.post(f"{base}/access", headers=headers, json={"password": "share-password"})
|
|
||||||
assert access.status_code == 200
|
|
||||||
config = await env.client.post(f"{base}/editor-config", headers=headers,
|
|
||||||
json={**body, "access_token": access.json()["access_token"]})
|
|
||||||
assert config.status_code == 200 and config.headers["cache-control"] == "no-store"
|
|
||||||
assert config.json()["file_name"] == item.title and config.json()["access_mode"] == "edit"
|
|
||||||
assert not config.json()["can_save_as"] and item.study_id is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_revision_names_reuse_collaboration_metadata_without_replacing_content(env):
|
|
||||||
item = await grant(env, env.editor, "EDITOR")
|
|
||||||
await grant(env, env.viewer, "VIEWER")
|
|
||||||
revision_id = item.current_revision_id
|
|
||||||
generation = item.generation
|
|
||||||
base = f"/api/v1/ledgers/{item.id}/revisions/{revision_id}"
|
|
||||||
env.identity.user = env.editor
|
|
||||||
result = await env.client.patch(base, json={"change_summary": "审核提交版"})
|
|
||||||
assert result.status_code == 200 and result.json()["change_summary"] == "审核提交版"
|
|
||||||
assert item.current_revision_id == revision_id and item.generation == generation
|
|
||||||
assert len(await collaboration.list_revisions(env.db, item)) == 1
|
|
||||||
for actor, status in [(env.viewer, 403), (env.outsider, 404)]:
|
|
||||||
env.identity.user = actor
|
|
||||||
assert (await env.client.patch(base, json={"change_summary": "不应保存"})).status_code == status
|
|
||||||
env.identity.user = env.editor
|
|
||||||
other_revision = env.initial[1].current_revision_id
|
|
||||||
assert (await env.client.patch(f"/api/v1/ledgers/{item.id}/revisions/{other_revision}", json={"change_summary": "跨台账"})).status_code == 404
|
|
||||||
env.identity.user = env.admin
|
|
||||||
await env.client.patch(f"/api/v1/ledgers/{item.id}", json={
|
|
||||||
"title": item.title, "status": "ARCHIVED", "owner_id": str(item.owner_id), "generation": item.generation,
|
|
||||||
})
|
|
||||||
assert (await env.client.patch(base, json={"change_summary": "归档后"})).status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_history_export_uses_live_export_permission_and_checks_revision_belongs_to_ledger(env):
|
|
||||||
item = await grant(env, env.editor, "EDITOR")
|
|
||||||
await grant(env, env.viewer, "VIEWER")
|
|
||||||
base = f"/api/v1/ledgers/{item.id}"
|
|
||||||
path = f"{base}/revisions/{item.current_revision_id}/download"
|
|
||||||
for actor in [env.editor, env.viewer]:
|
|
||||||
env.identity.user = actor
|
|
||||||
assert (await env.client.get(path)).status_code == 403
|
|
||||||
env.identity.user = env.admin
|
|
||||||
response = await env.client.get(path)
|
|
||||||
assert response.status_code == 200 and response.headers["cache-control"] == "no-store"
|
|
||||||
revision = await env.db.get(CollaborationRevision, item.current_revision_id)
|
|
||||||
assert response.content == Path(revision.file_uri).read_bytes()
|
|
||||||
await env.client.patch(f"{base}/access-settings", json={"generation": item.generation, "allow_export": True})
|
|
||||||
env.identity.user = env.viewer
|
|
||||||
assert (await env.client.get(path)).status_code == 200
|
|
||||||
assert (await env.client.get(f"{base}/revisions/{env.initial[1].current_revision_id}/download")).status_code == 404
|
|
||||||
await ledgers.remove_member(env.db, item, env.viewer.id, env.admin)
|
|
||||||
assert (await env.client.get(path)).status_code == 404
|
|
||||||
env.identity.user = env.outsider
|
|
||||||
assert (await env.client.get(path)).status_code == 404
|
|
||||||
@@ -119,10 +119,6 @@ services:
|
|||||||
FRONTEND_PUBLIC_URL: ${FRONTEND_PUBLIC_URL:-http://localhost:8888}
|
FRONTEND_PUBLIC_URL: ${FRONTEND_PUBLIC_URL:-http://localhost:8888}
|
||||||
LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-}
|
LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-}
|
||||||
LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default}
|
LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default}
|
||||||
ONLYOFFICE_ENABLED: ${ONLYOFFICE_ENABLED:-true}
|
|
||||||
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:?请先运行安装脚本生成 ONLYOFFICE_JWT_SECRET}
|
|
||||||
ONLYOFFICE_INTERNAL_URL: ${ONLYOFFICE_INTERNAL_URL:-http://onlyoffice}
|
|
||||||
ONLYOFFICE_INSTANCE_ID: ${ONLYOFFICE_INSTANCE_ID:?请先运行安装脚本生成 ONLYOFFICE_INSTANCE_ID}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ npm run desktop:build:app
|
|||||||
|
|
||||||
- [ ] `frontend/package.json`、`package-lock.json`、Tauri 配置、Cargo manifest/lock 版本一致。
|
- [ ] `frontend/package.json`、`package-lock.json`、Tauri 配置、Cargo manifest/lock 版本一致。
|
||||||
- [ ] `VITE_BUILD_CHANNEL=release` 和 `VITE_BUILD_COMMIT=<release tag commit>` 由 CI 注入,且 `npm run release:env:check` 通过。
|
- [ ] `VITE_BUILD_CHANNEL=release` 和 `VITE_BUILD_COMMIT=<release tag commit>` 由 CI 注入,且 `npm run release:env:check` 通过。
|
||||||
- [ ] 在正式 release tag 环境中执行 `npm run desktop:release-readiness:check`,确认 tag、构建元数据、当前精确版本的平台签名策略、updater 私钥和生产 artifact HTTPS 基址齐备。
|
- [ ] 在正式 release tag 和签名环境中执行 `npm run desktop:release-readiness:check`,确认 tag、构建元数据、签名/公证变量、updater 私钥和生产 artifact HTTPS 基址齐备。
|
||||||
- [ ] macOS app 已完成 Apple 签名/公证;若当前精确版本获批平台签名例外,则已验证 `Signature=adhoc`、确认未公证并附带 Gatekeeper 风险说明。
|
- [ ] macOS app 已签名和公证。
|
||||||
- [ ] Windows 应用和 NSIS 安装器已使用组织证书签名并带有效 RFC 3161 时间戳,`Get-AuthenticodeSignature` 返回 `Valid`;若当前精确版本获批例外,则应用和安装器必须返回 `NotSigned` 并附带 SmartScreen 风险说明。
|
- [ ] Windows 应用和 NSIS 安装器已使用组织证书签名并带有效 RFC 3161 时间戳,`Get-AuthenticodeSignature` 对应用和安装器均返回 `Valid`。
|
||||||
- [ ] updater `.sig` 使用组织 CI secret 或密钥库中的私钥生成,私钥未进入仓库。
|
- [ ] updater `.sig` 使用组织 CI secret 或密钥库中的私钥生成,私钥未进入仓库。
|
||||||
- [ ] 始终设置 `TAURI_SIGNING_PRIVATE_KEY`、`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 和 `REQUIRE_UPDATER_SIGNING=true`;默认 macOS 路径再设置 Apple 变量和 `REQUIRE_DESKTOP_SIGNING=true`,例外路径则设置 `DESKTOP_PLATFORM_SIGNING_MODE=unsigned-exception`、`ALLOW_UNSIGNED_PLATFORM_RELEASE=true` 并执行 `npm run desktop:build:macos-unsigned-release -- --ci`。
|
- [ ] 设置 `TAURI_SIGNING_PRIVATE_KEY`、`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 和 Apple 签名/公证变量后,以 `REQUIRE_DESKTOP_SIGNING=true` 再次执行 `npm run release:env:check`,随后执行 `npm run desktop:build:macos-release -- --ci`。
|
||||||
- [ ] 默认 Windows 路径设置 PFX、密码、`WINDOWS_TIMESTAMP_URL` 和 `REQUIRE_WINDOWS_SIGNING=true`;例外路径不得伪装签名状态,构建后必须对应用和安装器验证 `NotSigned`。
|
- [ ] 设置 Windows PFX、密码、`WINDOWS_TIMESTAMP_URL` 和 updater 私钥后,以 `REQUIRE_WINDOWS_SIGNING=true` 执行 readiness,再执行 `npm run desktop:build:windows-release -- --ci`。
|
||||||
- [ ] 正式 updater feed 使用 `--artifact <CTMS.app.tar.gz>` 和 `--platform-artifact windows-x86_64=<CTMS.nsis.zip>` 汇总生成同一个 `latest.json` 与 `SHA256SUMS.txt`。
|
- [ ] 正式 updater feed 使用 `--artifact <CTMS.app.tar.gz>` 和 `--platform-artifact windows-x86_64=<CTMS.nsis.zip>` 汇总生成同一个 `latest.json` 与 `SHA256SUMS.txt`。
|
||||||
- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64 --require-provenance`,并确认两个平台的 updater artifact、`.sig`、安装包、provenance、checksum manifest 和 `latest.json` 均通过校验。
|
- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64`,并确认两个平台的 updater artifact、`.sig`、安装包、checksum manifest 和 `latest.json` 均通过校验。
|
||||||
- [ ] 不可变制品先上传,`latest.json` 最后原子替换;若 feed 校验未通过,不替换线上 `latest.json`。
|
- [ ] 不可变制品先上传,`latest.json` 最后原子替换;若 feed 校验未通过,不替换线上 `latest.json`。
|
||||||
- [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。
|
- [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ npm run desktop:build:app
|
|||||||
- [ ] updater capability 不直接暴露给 WebView,自动更新只走受控 Tauri command。
|
- [ ] updater capability 不直接暴露给 WebView,自动更新只走受控 Tauri command。
|
||||||
- [ ] 更新弹窗 release notes 过滤 URL、token 查询参数和 Authorization/Bearer 形态文本。
|
- [ ] 更新弹窗 release notes 过滤 URL、token 查询参数和 Authorization/Bearer 形态文本。
|
||||||
- [ ] CI release 候选 workflow 包含 version/runtime/desktop/ui/type/unit/build/desktop app smoke 门禁。
|
- [ ] CI release 候选 workflow 包含 version/runtime/desktop/ui/type/unit/build/desktop app smoke 门禁。
|
||||||
- [ ] Desktop release candidate workflow 只允许从 `vX.Y.Z` tag 运行;先按精确版本解析平台签名策略,macOS 和 Windows 原生 job 分别验证实际签名状态,再由聚合 job 生成包含 `darwin-aarch64`、`darwin-x86_64` 和 `windows-x86_64` 的 feed、provenance、checksum 清单和 verified release directory。
|
- [ ] signed Desktop release candidate workflow 只允许从 `vX.Y.Z` tag 运行;macOS 和 Windows 原生 job 分别完成签名验证,再由聚合 job 生成包含 `darwin-aarch64`、`darwin-x86_64` 和 `windows-x86_64` 的 feed、checksum 清单和 verified release directory。
|
||||||
|
|
||||||
人工复审还必须确认:
|
人工复审还必须确认:
|
||||||
|
|
||||||
@@ -88,8 +88,7 @@ npm run desktop:build:app
|
|||||||
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
|
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
|
||||||
| 记住密码 | 必测 | 必测 | Web 使用浏览器凭据管理/自动填充;Desktop 使用系统凭据库;未勾选时不继续写入保存密码 |
|
| 记住密码 | 必测 | 必测 | Web 使用浏览器凭据管理/自动填充;Desktop 使用系统凭据库;未勾选时不继续写入保存密码 |
|
||||||
| 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 |
|
| 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 |
|
||||||
| 构建默认服务器地址已注入 | 不适用 | 必测 | 首次启动使用 `VITE_DESKTOP_SERVER_URL`,不要求重复输入;运行时代码不含生产域名 |
|
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
|
||||||
| 构建默认地址和本地配置均缺失 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
|
|
||||||
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
|
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
|
||||||
| 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 |
|
| 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 |
|
||||||
| 本地缓存命中 | 必测 | 必测 | `/me` 校验通过后可先展示缓存再后台刷新 |
|
| 本地缓存命中 | 必测 | 必测 | `/me` 校验通过后可先展示缓存再后台刷新 |
|
||||||
@@ -277,22 +276,6 @@ npm run desktop:build:app
|
|||||||
|
|
||||||
创建正式 tag 前仍必须由发布负责人确认:
|
创建正式 tag 前仍必须由发布负责人确认:
|
||||||
|
|
||||||
- GitHub Actions 已配置 updater secrets 和 versioned artifact base URL;默认平台签名路径还必须配置 Apple 与 Windows secrets 和 Windows timestamp URL。
|
- GitHub Actions 已配置 updater、Apple 与 Windows 签名 secrets,以及 versioned artifact base URL 和 Windows timestamp URL。
|
||||||
- 若使用默认 Windows 签名路径,组织证书允许 PFX 导入 CI;若为硬件或云托管密钥,先将 workflow 切换为经审计的 Tauri `signCommand` provider。
|
- 组织 Windows 证书允许 PFX 导入 CI;若为硬件或云托管密钥,先将 workflow 切换为经审计的 Tauri `signCommand` provider。
|
||||||
- macOS/Windows 原生 candidate job、联合 feed 校验、真实安装/升级和生产下载源上传全部通过。
|
- macOS/Windows 原生 candidate job、联合 feed 校验、真实安装/升级和生产下载源上传全部通过。
|
||||||
|
|
||||||
## 13. 2026-07-17 v0.1.0 平台签名例外记录
|
|
||||||
|
|
||||||
经发布负责人明确批准,v0.1.0 可在无法提供 Apple Developer 和 Windows 组织代码签名凭据时受控分发。该批准仅覆盖精确版本 `0.1.0`:
|
|
||||||
|
|
||||||
- `frontend/desktop-release-policy.json` 默认仍为 `signed`,仅将 v0.1.0 列入 `unsignedPlatformExceptions`;后续版本不会继承本例外。
|
|
||||||
- macOS 使用 Tauri `signingIdentity: "-"` 生成 ad-hoc 签名 Universal app/DMG,必须通过 `codesign --verify` 并确认 `Signature=adhoc`,不执行或声称 Apple 公证。
|
|
||||||
- Windows 应用和 NSIS 安装器不做 Authenticode 签名,必须通过 `Get-AuthenticodeSignature` 确认 `NotSigned`,不执行或声称 RFC 3161 时间戳。
|
|
||||||
- 两端仍必须从同一 `v0.1.0` tag 和 SHA 构建,使用同一 updater 私钥生成 `.sig`,并通过联合 feed、checksum 和 provenance 校验。
|
|
||||||
- GitHub Actions 额度不可用期间,发布负责人额外批准从最终 `v0.1.0` tag/SHA 在受控 macOS 主机本地构建并先行上传 macOS 制品;Windows 必须在恢复后从同一 tag/SHA 后补。
|
|
||||||
- macOS 先行的用户可见 GitHub Release 只包含 DMG、仅覆盖该 DMG 的 checksum 和 GitHub 自动源码归档;Release Notes 必须明确 macOS ad-hoc/未公证风险和 Windows pending,且不得包含或替换生产 `latest.json`。updater 包及 `.sig`、完整 checksum、provenance、`UNSIGNED-PLATFORM` 和 Windows pending 证据必须先复制到权限受限且被 Git 忽略的私有发布目录。
|
|
||||||
- Windows `NotSigned` 实物和联合 feed 全部验证通过后,才允许激活生产 updater feed;tag 不得为补充 Windows 制品而移动。
|
|
||||||
- 制品与 Actions artifact 名称必须含 `_UNSIGNED` 或 `UNSIGNED-PLATFORM`,私有证据/完整 updater 发布目录必须携带 `UNSIGNED-PLATFORM-RELEASE.txt` 和 `DESKTOP-RELEASE-PROVENANCE.json`;installer-only GitHub Release 通过 `_UNSIGNED` 安装包名和 Release Notes 呈现风险,不要求公开展示内部证据文件。
|
|
||||||
- 受控分发说明必须明确提示 macOS Gatekeeper 与 Windows SmartScreen 警告;平台未签名制品不得描述为 Apple/Microsoft 信任或已公证/已 Authenticode 签名。
|
|
||||||
|
|
||||||
本地实物验证已完成:使用当前 updater 私钥成功构建 Universal `x86_64 arm64` macOS app、DMG、`.app.tar.gz` 和 `.sig`;`codesign --verify --deep --strict` 通过,签名详情为 `Signature=adhoc`、`TeamIdentifier=not set`,Gatekeeper 拒绝符合预期。Windows `NotSigned`、NSIS 和 updater `.sig` 仍须由 `windows-latest` 原生 job 在正式 tag 上验证。
|
|
||||||
|
|||||||
@@ -305,33 +305,9 @@ Before promoting `main` to `release`, confirm:
|
|||||||
|
|
||||||
- release checklist is complete
|
- release checklist is complete
|
||||||
- production initialization, build, and smoke verification are complete
|
- production initialization, build, and smoke verification are complete
|
||||||
- Desktop updater signatures are present and verified
|
|
||||||
- platform signing is either complete or the exact version has an approved,
|
|
||||||
checked-in exception in `frontend/desktop-release-policy.json`; the current
|
|
||||||
v0.1.0 exception requires macOS ad-hoc signing, unsigned Windows artifacts,
|
|
||||||
controlled distribution, and explicit trust warnings
|
|
||||||
- rollback or remediation path is clear
|
- rollback or remediation path is clear
|
||||||
- release tag has been prepared
|
- release tag has been prepared
|
||||||
|
|
||||||
Platform signing remains the default for versions without an exact approved
|
|
||||||
exception. An exception never permits disabling Tauri updater signatures or
|
|
||||||
building Web, macOS, and Windows from different tags or commits.
|
|
||||||
|
|
||||||
For v0.1.0 only, the release owner also approved a staged contingency while
|
|
||||||
hosted Actions capacity is unavailable: macOS may be built on a controlled
|
|
||||||
local macOS host from the immutable final `v0.1.0` tag and published first.
|
|
||||||
Windows remains pending and must later be built from that same tag and SHA.
|
|
||||||
The user-facing macOS-only GitHub Release may contain only the DMG and a
|
|
||||||
checksum manifest that covers that installer; platform-trust and
|
|
||||||
Windows-pending status must remain explicit in the Release Notes. The updater
|
|
||||||
package and signature, full checksum manifest, provenance, warning, and
|
|
||||||
Windows-pending evidence must be retained in a Git-ignored private release
|
|
||||||
directory. After Windows is built from the same tag and SHA, those updater
|
|
||||||
artifacts may be published only to a separately configured anonymous HTTPS
|
|
||||||
update origin. The staged release must not publish or replace production
|
|
||||||
`latest.json`; updater feed activation waits for combined macOS and Windows
|
|
||||||
verification.
|
|
||||||
|
|
||||||
Reference:
|
Reference:
|
||||||
|
|
||||||
- [docs/guides/release-checklist.md](guides/release-checklist.md)
|
- [docs/guides/release-checklist.md](guides/release-checklist.md)
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
# 全局协作台账(第一版)
|
|
||||||
|
|
||||||
台账与项目平级,从网页端、桌面端工作台进入 `/ledgers/:fileId`,不依赖项目 ID、项目成员或项目权限。进入台账时清理当前项目上下文。
|
|
||||||
|
|
||||||
## 初始模板
|
|
||||||
|
|
||||||
系统管理员在工作台点击“初始化两本台账”,幂等创建:
|
|
||||||
|
|
||||||
- 医学事务部合同台账明细表:华邦云合同编号、医学事务部合同编号、项目代码、合同内容、备注。
|
|
||||||
- 医学事务部临床运营项目编号:项目编号、产品名称、产品代码、临床试验内容、生效日期。
|
|
||||||
|
|
||||||
模板来自用户提供的同名 XLSX,保存在 `backend/app/templates/ledgers/`;原文件只有表头和空白格式行,无业务记录。初始工作表、列宽、单元格样式保持原样,仅应用现有工作簿结构保护。管理员可修改名称、说明或导入新的 XLSX;导入产生新修订并保留原版本,不改变台账 ID 和授权。
|
|
||||||
|
|
||||||
## 权限
|
|
||||||
|
|
||||||
每本台账独立授予系统内有效账号 `VIEWER`、`EDITOR`、`MANAGER`。所有者及系统管理员具有管理权限,其隐含权限显示在授权列表中且不能移除;所有者或系统管理员可将所有权转让给有效系统账号,原所有者保留管理权限。未授权账号看不到台账,直接访问返回 404。无项目成员身份的有效账号也可获得台账授权。
|
|
||||||
|
|
||||||
查看者只读,编辑者可编辑内容,管理者可修改设置、维护授权、归档、导入和恢复历史版本。文件与历史版本不提供删除 API,协作底层删除函数同时拒绝台账删除。归档后所有账号均以只读模式打开。项目协作编辑者原有的历史恢复权限不扩展到台账。
|
|
||||||
|
|
||||||
“不可删除”指整个台账文件和历史版本。单元格、行和记录允许编辑及清空;工作簿结构保护控制工作表增删,不提供记录级防删除或单元格审计。文件历史版本不等同于逐项业务审计。
|
|
||||||
|
|
||||||
查看者/编辑者及匿名访问者的下载、打印和复制由台账导出开关控制;管理者始终可导出。台账不支持另存为项目文件。
|
|
||||||
|
|
||||||
台账卡片的“台账管理”提供三个并列入口,各自打开独立弹窗,不通过 Tab 或弹窗内链接跳转:
|
|
||||||
|
|
||||||
- 信息维护:`LedgerInfoDialog.vue` 维护名称、用途说明和使用/归档状态,展示所有者。
|
|
||||||
- 访问与权限:与项目共同使用 `CollaborationAccessDialog.vue`,通过 `collaborationAccess.ts` 适配各自 API;包含账号授权、协作者选择、匿名访问、权限设置、编辑申请审批及所有权转让。项目选择器继续使用项目成员;台账选择器使用系统有效账号,不需要项目上下文。
|
|
||||||
- 历史版本:`LedgerHistoryDialog.vue` 作为台账入口,直接复用项目的 `CollaborationHistoryDialog.vue`。按日期分组显示时间、更新者、大小、来源、版本名称和当前版本,支持仅看已命名版本、来源筛选、手动刷新和延迟保存回调刷新。编辑者及管理者可命名版本,导入及恢复限使用中台账的管理者;台账历史始终不提供删除操作。预览复用完整的 Office 只读预览页,通过不含项目 ID 的台账路由打开;另存为按当前导出权限保存本地 XLSX,项目历史另存为仍创建项目工作区文件。
|
|
||||||
|
|
||||||
所有弹窗挂载到页面根节点。信息维护只加载台账元数据,历史版本单独加载版本列表;关闭后重新打开会刷新数据。
|
|
||||||
|
|
||||||
匿名访问默认关闭。管理员可以使用与项目相同的只读/编辑模式、有效期、密码和复制链接功能,复用现有公开分享入口、密码校验和签名凭证。台账分享配置变化会更新链接版本和文件代次,之前复制的台账链接及密码凭证随之失效,需复制新链接。已授权查看者可在开放申请时提交编辑申请;管理者在访问与权限中审批,台账申请不会写入项目通知。
|
|
||||||
|
|
||||||
## 协作与保存边界
|
|
||||||
|
|
||||||
复用 `CollaborationWorkspace.vue`、ONLYOFFICE 配置、会话、签名回调和不可变内容修订。`collaboration_files.scope` 显式区分 `PROJECT` 与 `LEDGER`;数据库约束保证台账的 `study_id`/`folder_id` 为空且只允许 XLSX。台账内容保存在上传目录的 `collaboration/ledgers/<file-id>/`,不会进入项目目录。
|
|
||||||
|
|
||||||
设置/授权变化、编辑申请获批、所有权转让、匿名访问变更、导入、恢复推进文件代次;旧代次保存回调不覆盖新内容。台账回调在文件锁内复核代次、归档状态与保存账号有效编辑权限;匿名回调另行检查链接仍启用、未过期且允许编辑,不回退为所有者身份。客户端每 15 秒及窗口重新获得焦点时复核账号访问状态,权限/代次变化或验证失败时卸载编辑器并提示重新打开。台账 API 禁用缓存和自动网络重试,不使用离线存储。
|
|
||||||
|
|
||||||
信息维护、导入及历史恢复在更新前提示协作者先保存;访问与权限沿用项目界面的即时保存交互。变更代次会使原会话未保存内容无法继续写入,权限变更前应先保存内容。授权撤销立即阻断旧代次保存,已显示的账号编辑页面会在下一次客户端检查时关闭。历史预览沿用已有短时签名内部只读内容链路。
|
|
||||||
|
|
||||||
## 迁移与验证
|
|
||||||
|
|
||||||
迁移 `20260903_01` 位于 `20260716_05` 之后;已有文件默认为 `PROJECT`。首次创建台账后,降级迁移主动拒绝删除台账数据,应保留数据并前向修复。
|
|
||||||
|
|
||||||
验证覆盖模板逐部件保真、初始化幂等、独立账号授权、未授权 API、查看/编辑配置、文件和历史禁止删除、撤权/停用后的保存拒绝、回调幂等、归档、导入与恢复,以及历史命名和导出的角色限制、跨台账隔离。前端覆盖无项目入口、初始化、加载失败重试、全局编辑器及权限变化卸载,以及共享历史的筛选、命名、恢复、另存为与预览。
|
|
||||||
|
|
||||||
本次未新增 Tauri command、capability、CSP 或底层存储能力,文件导入与下载使用现有运行时入口;现有桌面发布检查脚本适用。
|
|
||||||
@@ -137,9 +137,7 @@ bash scripts/install.sh release --base-url https://ctms.example.com
|
|||||||
可选参数:
|
可选参数:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
--base-url <url> 对外访问基址,同时用于健康检查及 ONLYOFFICE 保存地址校验。dev/main 默认
|
--base-url <url> 健康检查使用的访问地址。dev/main 默认 http://127.0.0.1:8888,release 必填或交互输入。
|
||||||
http://127.0.0.1:8888;已有环境优先复用 .env 的 CTMS_BASE_URL,
|
|
||||||
首次 release 安装必填或交互输入。只接受协议与主机组成的 origin。
|
|
||||||
--yes 跳过交互确认,适合自动化执行。
|
--yes 跳过交互确认,适合自动化执行。
|
||||||
--skip-build 跳过镜像构建,仍会执行 docker compose up -d。
|
--skip-build 跳过镜像构建,仍会执行 docker compose up -d。
|
||||||
--skip-migrate 跳过 alembic upgrade head。
|
--skip-migrate 跳过 alembic upgrade head。
|
||||||
@@ -149,14 +147,12 @@ bash scripts/install.sh release --base-url https://ctms.example.com
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
1. 检查 docker、docker compose、openssl、curl。
|
1. 检查 docker、docker compose、openssl、curl。
|
||||||
2. 检查 .env;保留已有身份与登录密钥,补齐并默认启用标准 ONLYOFFICE 配置,不存在则新建;将
|
2. 检查 .env;保留已有身份与登录密钥,补齐并默认启用标准 ONLYOFFICE 配置,不存在则新建。
|
||||||
`CTMS_BASE_URL` 与 `FRONTEND_PUBLIC_URL` 同步为 `--base-url`,确保公开缓存 URL 可通过保存回调校验。
|
|
||||||
3. 所有环境自动生成独立的 ONLYOFFICE JWT 与稳定实例标识;新建 main/release 的 .env 时同时生成登录 JWT 和 RSA 私钥。
|
3. 所有环境自动生成独立的 ONLYOFFICE JWT 与稳定实例标识;新建 main/release 的 .env 时同时生成登录 JWT 和 RSA 私钥。
|
||||||
4. main/release 先构建并执行 backend-init,确保数据库迁移使用当前代码中的 Alembic revision。
|
4. main/release 先构建并执行 backend-init,确保数据库迁移使用当前代码中的 Alembic revision。
|
||||||
5. 在重启服务前通过 ONLYOFFICE `info` 命令检查真实在线编辑者;存在在线编辑时中止部署,待用户退出并完成最终保存后重试。
|
5. 默认构建并启动数据库、后端、前端/Nginx 和 ONLYOFFICE;ONLYOFFICE 不再使用可选 Compose Profile。
|
||||||
6. 默认构建并启动数据库、后端、前端/Nginx 和 ONLYOFFICE;ONLYOFFICE 不再使用可选 Compose Profile。
|
6. 执行 docker compose run --rm backend python -m alembic upgrade head。
|
||||||
7. 执行 docker compose run --rm backend python -m alembic upgrade head。
|
7. 检查全部容器状态、后端环境、/health、/api/v1/auth/login-key 和 /onlyoffice/healthcheck。
|
||||||
8. 检查全部容器状态、后端环境、/health、/api/v1/auth/login-key 和 /onlyoffice/healthcheck。
|
|
||||||
```
|
```
|
||||||
|
|
||||||
更新进入“执行部署更新”后会持续显示 Docker 镜像拉取与构建进度:交互终端使用滚动实时输出窗口,CI、远程面板或管道环境直接流式输出构建日志,并在两种模式下显示累计耗时。
|
更新进入“执行部署更新”后会持续显示 Docker 镜像拉取与构建进度:交互终端使用滚动实时输出窗口,CI、远程面板或管道环境直接流式输出构建日志,并在两种模式下显示累计耗时。
|
||||||
@@ -197,8 +193,6 @@ cat > .env <<'EOF'
|
|||||||
COMPOSE_PROJECT_NAME=ctms_dev
|
COMPOSE_PROJECT_NAME=ctms_dev
|
||||||
ENV=development
|
ENV=development
|
||||||
JWT_SECRET_KEY=dev-secret
|
JWT_SECRET_KEY=dev-secret
|
||||||
CTMS_BASE_URL=http://127.0.0.1:8888
|
|
||||||
FRONTEND_PUBLIC_URL=http://127.0.0.1:8888
|
|
||||||
LOGIN_RSA_KEY_ID=default
|
LOGIN_RSA_KEY_ID=default
|
||||||
LOGIN_RSA_PRIVATE_KEY=
|
LOGIN_RSA_PRIVATE_KEY=
|
||||||
ONLYOFFICE_ENABLED=true
|
ONLYOFFICE_ENABLED=true
|
||||||
|
|||||||
@@ -106,9 +106,7 @@ npm run desktop:build:app
|
|||||||
npm run desktop:build:app
|
npm run desktop:build:app
|
||||||
```
|
```
|
||||||
|
|
||||||
正式桌面发布构建必须从同一正式 tag 分别在 macOS 和 Windows 原生 CI job 执行。两端都必须设置 updater 签名私钥,updater 签名不可关闭。平台签名默认路径仍要求 macOS 设置 Apple 签名/公证变量并以 `REQUIRE_DESKTOP_SIGNING=true` 执行,Windows 设置 PFX、密码和 RFC 3161 时间戳变量并以 `REQUIRE_WINDOWS_SIGNING=true` 执行。只有 `frontend/desktop-release-policy.json` 对当前精确版本存在已批准例外时,workflow 才可改为 macOS ad-hoc、Windows Authenticode 未签名路径;例外制品必须标注 `UNSIGNED-PLATFORM`、生成 provenance 和风险说明。两个平台与联合 feed 校验通过后才能汇总正式 updater feed。
|
正式桌面发布构建必须从同一正式 tag 分别在 macOS 和 Windows 原生 CI job 执行。两端都必须设置 updater 签名私钥;macOS 设置 Apple 签名/公证变量并以 `REQUIRE_DESKTOP_SIGNING=true` 执行 readiness 与 Universal 构建,Windows 设置 PFX 证书、密码和 RFC 3161 时间戳变量并以 `REQUIRE_WINDOWS_SIGNING=true` 执行 readiness、签名 NSIS 构建和 Authenticode 校验。两个平台通过后才能汇总正式 updater feed。
|
||||||
|
|
||||||
v0.1.0 额外批准 GitHub Actions 额度不可用时的分阶段应急路径:先在受控 macOS 主机从最终、不可移动的 `v0.1.0` tag/SHA 本地构建 macOS ad-hoc 制品,面向安装用户的 GitHub Release 只保留 DMG 与仅覆盖该安装包的 checksum,平台未签名警告和 Windows pending 状态写入 Release Notes。macOS updater 包及 `.sig`、完整 checksum、provenance、风险说明和 Windows pending 证据必须保存在被 Git 忽略的私有发布目录;Windows 恢复后必须从同一 tag/SHA 后补,并在联合验证通过后把 updater 制品发布到可匿名读取的独立 HTTPS 更新源。macOS 先行阶段不得发布或替换生产 `latest.json`,联合 feed 必须等待 Windows `NotSigned` 实物验证完成。
|
|
||||||
|
|
||||||
后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。
|
后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。
|
||||||
|
|
||||||
@@ -208,7 +206,7 @@ dev -> main
|
|||||||
- 前后端测试通过
|
- 前后端测试通过
|
||||||
- 网页端构建通过
|
- 网页端构建通过
|
||||||
- macOS 桌面端构建通过
|
- macOS 桌面端构建通过
|
||||||
- Windows x64 NSIS 兼容性构建通过;正式发布时平台签名和时间戳验证通过,或当前精确版本的已批准平台签名例外验证通过
|
- Windows x64 NSIS 兼容性构建通过;正式发布时签名和时间戳验证通过
|
||||||
- 数据库迁移经过验证
|
- 数据库迁移经过验证
|
||||||
- 已知风险和回滚方式已记录
|
- 已知风险和回滚方式已记录
|
||||||
|
|
||||||
@@ -297,9 +295,7 @@ git tag -a v1.2.0 -m "CTMS v1.2.0"
|
|||||||
git push origin v1.2.0
|
git push origin v1.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
网页端、macOS 和 Windows 桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。两个平台的 updater 签名制品必须始终验证通过;macOS 签名/公证和 Windows 代码签名/RFC 3161 时间戳必须验证通过,除非 `frontend/desktop-release-policy.json` 对该精确版本记录了已批准例外。采用例外时必须验证 macOS 确为 ad-hoc、Windows 确为 `NotSigned`,并在 Release Notes、私有发布证据、完整 updater checksum 和 provenance 中记录 `UNSIGNED-PLATFORM` 风险;面向安装用户的 Release 可按精确版本策略只显示安装包与安装包 checksum,但 updater 制品及验证证据必须保留,之后才能最后原子替换生产 `latest.json`。
|
网页端、macOS 和 Windows 桌面端必须从同一个 `v1.2.0` 标签构建。不得从不同分支、不同提交或本地未提交状态构建正式制品。macOS 签名/公证、Windows 代码签名/RFC 3161 时间戳以及两个平台的 updater 签名制品必须全部验证通过后,才能分发安装包并最后原子替换生产 `latest.json`。
|
||||||
|
|
||||||
若执行 v0.1.0 的已批准分阶段应急路径,首次 macOS Release 可先于 Windows 发布,但 tag/SHA 不得移动;GitHub Release 只保留 DMG、仅覆盖该 DMG 的 checksum 和 GitHub 自动提供的源码归档,并在 Release Notes 中明确标记未签名风险与 Windows pending。updater 包、`.sig`、完整 checksum、provenance 和说明文件必须在私有发布目录留存。Windows 后补且联合 feed 验证通过后,才允许把这些 updater 制品发布到独立匿名 HTTPS 更新源并按上述顺序激活生产 updater feed。
|
|
||||||
|
|
||||||
发布记录至少包含:
|
发布记录至少包含:
|
||||||
|
|
||||||
@@ -454,7 +450,6 @@ git log --oneline --decorate --graph --all -30
|
|||||||
- [ ] `npm run build` 通过
|
- [ ] `npm run build` 通过
|
||||||
- [ ] `npm run desktop:build:app` 通过
|
- [ ] `npm run desktop:build:app` 通过
|
||||||
- [ ] 正式桌面发布构建已使用 updater 签名私钥执行
|
- [ ] 正式桌面发布构建已使用 updater 签名私钥执行
|
||||||
- [ ] 平台签名已验证,或当前精确版本已在 `frontend/desktop-release-policy.json` 获批例外且 Release Notes、私有 `UNSIGNED-PLATFORM` 证据、provenance 和完整 updater checksum 齐备;若 GitHub Release 采用 installer-only profile,公开 checksum 只覆盖公开安装包
|
|
||||||
- [ ] 数据库迁移与回滚方案确认
|
- [ ] 数据库迁移与回滚方案确认
|
||||||
- [ ] 发布说明完成
|
- [ ] 发布说明完成
|
||||||
- [ ] `main -> release` 合并完成
|
- [ ] `main -> release` 合并完成
|
||||||
|
|||||||
@@ -87,11 +87,7 @@ npm run desktop:build:app
|
|||||||
|
|
||||||
`release:env:check` verifies build channel and commit metadata, and becomes
|
`release:env:check` verifies build channel and commit metadata, and becomes
|
||||||
platform-strict with `REQUIRE_DESKTOP_SIGNING=true` on macOS or
|
platform-strict with `REQUIRE_DESKTOP_SIGNING=true` on macOS or
|
||||||
`REQUIRE_WINDOWS_SIGNING=true` on Windows. Formal native jobs also set
|
`REQUIRE_WINDOWS_SIGNING=true` on Windows.
|
||||||
`REQUIRE_UPDATER_SIGNING=true`, `DESKTOP_RELEASE_PLATFORM`, and the
|
|
||||||
version-derived `DESKTOP_PLATFORM_SIGNING_MODE`. The exact-version policy in
|
|
||||||
`frontend/desktop-release-policy.json` is checked with
|
|
||||||
`npm run desktop:release-policy:check`.
|
|
||||||
`desktop:release:check` statically verifies the Tauri bundle, updater public
|
`desktop:release:check` statically verifies the Tauri bundle, updater public
|
||||||
key, CSP, capability scopes, command allowlist, query-token ban, generic system
|
key, CSP, capability scopes, command allowlist, query-token ban, generic system
|
||||||
notification boundary, CI gate coverage, and secure session token boundary. The
|
notification boundary, CI gate coverage, and secure session token boundary. The
|
||||||
@@ -136,10 +132,10 @@ The release pipeline must:
|
|||||||
|
|
||||||
1. build from the accepted release tag and commit;
|
1. build from the accepted release tag and commit;
|
||||||
2. build the Universal macOS app/DMG and Windows x64 NSIS installer from that tag;
|
2. build the Universal macOS app/DMG and Windows x64 NSIS installer from that tag;
|
||||||
3. use Apple signing/notarization and Windows Authenticode/RFC 3161 signing by default, or use a checked-in exact-version exception that constrains macOS to ad-hoc signing and Windows to Authenticode-unsigned output;
|
3. sign and notarize macOS, and Authenticode-sign Windows with an RFC 3161 timestamp;
|
||||||
4. produce macOS `.app.tar.gz` and Windows `.nsis.zip` updater artifacts plus their `.sig` files with the shared updater private key;
|
4. produce macOS `.app.tar.gz` and Windows `.nsis.zip` updater artifacts plus their `.sig` files with the shared updater private key;
|
||||||
5. generate `DESKTOP-RELEASE-PROVENANCE.json`, one combined `latest.json`, and a checksum manifest;
|
5. generate one combined `latest.json` and checksum manifest with `npm run desktop:update-feed:create`;
|
||||||
6. verify all macOS and Windows entries with `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64 --require-provenance`;
|
6. verify all macOS and Windows entries with `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64`;
|
||||||
7. upload immutable artifacts first;
|
7. upload immutable artifacts first;
|
||||||
8. atomically replace `latest.json` last.
|
8. atomically replace `latest.json` last.
|
||||||
|
|
||||||
@@ -147,61 +143,22 @@ For Universal macOS artifacts, `latest.json` must provide both
|
|||||||
`darwin-aarch64` and `darwin-x86_64` entries pointing at the same Universal
|
`darwin-aarch64` and `darwin-x86_64` entries pointing at the same Universal
|
||||||
update package.
|
update package.
|
||||||
|
|
||||||
The same feed must also contain `windows-x86_64`, pointing to the updater-signed
|
The same feed must also contain `windows-x86_64`, pointing to the signed NSIS
|
||||||
NSIS `.nsis.zip` package. The Windows `.exe` installer is distributed next to
|
`.nsis.zip` updater package. The Windows `.exe` installer is distributed next
|
||||||
the updater package but is not used as the updater URL. Tauri updater signing is
|
to the updater package but is not used as the updater URL.
|
||||||
mandatory in both platform-signed and platform-signing-exception modes.
|
|
||||||
|
|
||||||
The formal release candidate workflow lives at
|
The signed release candidate workflow lives at
|
||||||
`.github/workflows/desktop-release-candidate.yml`. It must be run from a
|
`.github/workflows/desktop-release-candidate.yml`. It must be run from a
|
||||||
matching `vX.Y.Z` tag and produces a verified release directory as a GitHub
|
matching `vX.Y.Z` tag and produces a verified release directory as a GitHub
|
||||||
artifact. That artifact is still only a release candidate; the release owner
|
artifact. That artifact is still only a release candidate; the release owner
|
||||||
must upload immutable files to the production download origin and replace
|
must upload immutable files to the production download origin and replace
|
||||||
`latest.json` atomically after validation.
|
`latest.json` atomically after validation.
|
||||||
|
|
||||||
Platform signing defaults to `signed`. An exception is allowed only when
|
|
||||||
`frontend/desktop-release-policy.json` names the exact product version and
|
|
||||||
records release-owner approval. The current v0.1.0 exception uses macOS ad-hoc
|
|
||||||
signing and unsigned Windows application/installer binaries. Its artifact names
|
|
||||||
and verified updater release directory contain `UNSIGNED-PLATFORM`; the private
|
|
||||||
evidence/update directory must include `UNSIGNED-PLATFORM-RELEASE.txt`,
|
|
||||||
`DESKTOP-RELEASE-PROVENANCE.json`, and the full `SHA256SUMS.txt`. The
|
|
||||||
user-facing GitHub Release may use the exact-version
|
|
||||||
`installer-and-checksum-only` profile, with platform warnings in Release Notes
|
|
||||||
and a separate checksum manifest covering only the displayed installer. This
|
|
||||||
does not establish Apple or Microsoft publisher trust, and Gatekeeper or
|
|
||||||
SmartScreen warnings are expected. Later versions return to the signed default
|
|
||||||
unless separately approved.
|
|
||||||
|
|
||||||
### v0.1.0 staged macOS contingency
|
|
||||||
|
|
||||||
The release owner approved one additional v0.1.0 contingency for unavailable
|
|
||||||
hosted Actions capacity. A controlled local macOS host may build the macOS
|
|
||||||
ad-hoc artifacts from the immutable final `v0.1.0` tag and publish them first.
|
|
||||||
That initial user-facing GitHub Release contains only the DMG and a
|
|
||||||
`SHA256SUMS.txt` that covers the DMG, in addition to GitHub's automatic source
|
|
||||||
archives. The Release Notes must state that macOS is ad-hoc/not notarized and
|
|
||||||
that Windows remains pending. The macOS updater package and `.sig`, full
|
|
||||||
checksum manifest, provenance, unsigned-platform warning, and Windows-pending
|
|
||||||
notice must be copied to a permission-restricted, Git-ignored private release
|
|
||||||
directory before any visible asset is removed. Windows must later be built from
|
|
||||||
the same tag and SHA.
|
|
||||||
|
|
||||||
The staged macOS release is an installer distribution, not an activated
|
|
||||||
cross-platform updater release. Do not upload or replace production
|
|
||||||
`latest.json` until the Windows `NotSigned` installer/updater has been built and
|
|
||||||
the combined macOS/Windows feed passes full verification. The combined updater
|
|
||||||
artifacts, signatures, provenance, full checksum manifest, and `latest.json`
|
|
||||||
must use a separately configured HTTPS update origin that anonymous production
|
|
||||||
clients can read; a private GitHub Release is not a valid production updater
|
|
||||||
origin. This fallback is limited to v0.1.0 and does not authorize local formal
|
|
||||||
builds for later versions.
|
|
||||||
|
|
||||||
## Windows Release and Internal Validation
|
## Windows Release and Internal Validation
|
||||||
|
|
||||||
Windows x64 NSIS is an approved formal Desktop target. Formal Windows builds
|
Windows x64 NSIS is an approved formal Desktop target. Formal Windows builds
|
||||||
run in `.github/workflows/desktop-release-candidate.yml` from the same exact
|
run in `.github/workflows/desktop-release-candidate.yml` from the same exact
|
||||||
`vX.Y.Z` tag and SHA as Web and macOS. The default signed path must:
|
`vX.Y.Z` tag and SHA as Web and macOS. They must:
|
||||||
|
|
||||||
- import a Base64-encoded PFX from `WINDOWS_CERTIFICATE` using
|
- import a Base64-encoded PFX from `WINDOWS_CERTIFICATE` using
|
||||||
`WINDOWS_CERTIFICATE_PASSWORD`;
|
`WINDOWS_CERTIFICATE_PASSWORD`;
|
||||||
@@ -213,12 +170,6 @@ run in `.github/workflows/desktop-release-candidate.yml` from the same exact
|
|||||||
- publish the signed NSIS `.exe`, `.nsis.zip`, and `.nsis.zip.sig` into the
|
- publish the signed NSIS `.exe`, `.nsis.zip`, and `.nsis.zip.sig` into the
|
||||||
combined verified Desktop release directory.
|
combined verified Desktop release directory.
|
||||||
|
|
||||||
For an approved exact-version unsigned-platform exception, the Windows job
|
|
||||||
must instead leave the application and installer Authenticode-unsigned, require
|
|
||||||
`Get-AuthenticodeSignature` to return `NotSigned`, append `_UNSIGNED` to the
|
|
||||||
installer and updater artifact names, and still create and verify the updater
|
|
||||||
`.sig`. A certificate secret and timestamp URL are not required in this mode.
|
|
||||||
|
|
||||||
Windows release validation also covers:
|
Windows release validation also covers:
|
||||||
|
|
||||||
- WebView2 runtime prerequisite behavior;
|
- WebView2 runtime prerequisite behavior;
|
||||||
@@ -235,33 +186,19 @@ only, runs on `windows-latest`, injects `VITE_BUILD_CHANNEL` and
|
|||||||
and Desktop safety gates, builds an unsigned NSIS installer with updater
|
and Desktop safety gates, builds an unsigned NSIS installer with updater
|
||||||
artifacts disabled, and uploads the `.exe` plus `SHA256SUMS.txt` as a GitHub
|
artifacts disabled, and uploads the `.exe` plus `SHA256SUMS.txt` as a GitHub
|
||||||
Actions artifact. This workflow is for internal compatibility verification
|
Actions artifact. This workflow is for internal compatibility verification
|
||||||
only; it must not generate `latest.json`, update feeds, verified release
|
only; it must not generate `latest.json`, update feeds, signed release
|
||||||
directories, or formal Windows release artifacts. A successful internal build
|
directories, or formal Windows release artifacts. A successful internal build
|
||||||
does not substitute for the tag-only formal workflow, including when that
|
does not substitute for the signed tag-only release workflow.
|
||||||
formal workflow uses an approved platform-signing exception.
|
|
||||||
|
|
||||||
The formal workflow always requires these organization settings:
|
The formal workflow requires these organization settings:
|
||||||
|
|
||||||
- secrets: `TAURI_SIGNING_PRIVATE_KEY` and
|
- secrets: `TAURI_SIGNING_PRIVATE_KEY`,
|
||||||
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`;
|
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, `WINDOWS_CERTIFICATE`, and
|
||||||
- client default CTMS origin: repository variable `VITE_DESKTOP_SERVER_URL`;
|
`WINDOWS_CERTIFICATE_PASSWORD`;
|
||||||
|
- variable: `WINDOWS_TIMESTAMP_URL`;
|
||||||
- shared versioned HTTPS artifact prefix: `DESKTOP_UPDATE_BASE_URL` or the
|
- shared versioned HTTPS artifact prefix: `DESKTOP_UPDATE_BASE_URL` or the
|
||||||
manual workflow input.
|
manual workflow input.
|
||||||
|
|
||||||
`VITE_DESKTOP_SERVER_URL` must be an HTTPS origin without credentials, path,
|
|
||||||
query, or fragment. Vite embeds it as the Desktop client's first-run default;
|
|
||||||
the runtime source must not contain the production hostname. A user can still
|
|
||||||
override this default in Desktop server settings, where the persisted manual
|
|
||||||
value takes precedence and switching origins clears the existing session and
|
|
||||||
server-scoped client state. Do not reuse this value as
|
|
||||||
`DESKTOP_UPDATE_BASE_URL`: the latter points to immutable updater artifacts,
|
|
||||||
not the CTMS business API.
|
|
||||||
|
|
||||||
The default signed path additionally requires `WINDOWS_CERTIFICATE`,
|
|
||||||
`WINDOWS_CERTIFICATE_PASSWORD`, and `WINDOWS_TIMESTAMP_URL`, plus the Apple
|
|
||||||
credentials documented by the release owner. The v0.1.0 exception does not
|
|
||||||
require those platform certificate settings.
|
|
||||||
|
|
||||||
The checked-in workflow implements the exportable PFX path. Confirm that the
|
The checked-in workflow implements the exportable PFX path. Confirm that the
|
||||||
organization's certificate policy permits an exportable CI certificate before
|
organization's certificate policy permits an exportable CI certificate before
|
||||||
provisioning it. If the selected CA provides only hardware- or cloud-backed
|
provisioning it. If the selected CA provides only hardware- or cloud-backed
|
||||||
@@ -277,7 +214,6 @@ npm ci
|
|||||||
npm run version:check
|
npm run version:check
|
||||||
export VITE_BUILD_CHANNEL=release
|
export VITE_BUILD_CHANNEL=release
|
||||||
export VITE_BUILD_COMMIT="$(git rev-parse HEAD)"
|
export VITE_BUILD_COMMIT="$(git rev-parse HEAD)"
|
||||||
export VITE_DESKTOP_SERVER_URL="https://ctms.example.com"
|
|
||||||
npm run release:env:check
|
npm run release:env:check
|
||||||
npm run runtime:check
|
npm run runtime:check
|
||||||
npm run desktop:release:check
|
npm run desktop:release:check
|
||||||
@@ -288,21 +224,16 @@ npm run build
|
|||||||
npm run desktop:build:app
|
npm run desktop:build:app
|
||||||
export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY"
|
export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY"
|
||||||
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD"
|
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD"
|
||||||
export REQUIRE_UPDATER_SIGNING=true
|
export REQUIRE_DESKTOP_SIGNING=true
|
||||||
export DESKTOP_RELEASE_PLATFORM=macos
|
|
||||||
export DESKTOP_PLATFORM_SIGNING_MODE=unsigned-exception
|
|
||||||
export ALLOW_UNSIGNED_PLATFORM_RELEASE=true
|
|
||||||
npm run release:env:check
|
npm run release:env:check
|
||||||
npm run desktop:release-readiness:check
|
npm run desktop:release-readiness:check
|
||||||
npm run desktop:build:macos-unsigned-release -- --ci
|
npm run desktop:build:macos-release -- --ci
|
||||||
npm run desktop:update-feed:create -- --artifact <CTMS.app.tar.gz> --platform-artifact windows-x86_64=<CTMS.nsis.zip> --include <CTMS.dmg> --include <CTMS-installer.exe> --base-url <versioned-https-artifact-prefix> --output-dir <release-dir>
|
npm run desktop:update-feed:create -- --artifact <CTMS.app.tar.gz> --platform-artifact windows-x86_64=<CTMS.nsis.zip> --include <CTMS.dmg> --include <CTMS-installer.exe> --base-url <versioned-https-artifact-prefix> --output-dir <release-dir>
|
||||||
npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64 --require-provenance
|
npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir> --require-platform windows-x86_64
|
||||||
```
|
```
|
||||||
|
|
||||||
The macOS and Windows builds run in their native CI jobs and always use the same
|
The macOS and Windows signed builds run in their native CI jobs. macOS requires
|
||||||
updater signing key. In the default path, macOS requires Apple
|
the Apple signing/notarization credentials defined by the release owner;
|
||||||
signing/notarization credentials and Windows requires the PFX/password and
|
Windows requires the PFX certificate/password and timestamp URL above. Both use
|
||||||
timestamp URL. In the approved v0.1.0 exception, macOS must verify
|
the same updater signing key and are aggregated only after both native jobs
|
||||||
`Signature=adhoc`, Windows must verify `NotSigned`, and both must carry explicit
|
pass. Unsigned internal builds are not formal distributions.
|
||||||
platform-trust warnings. Native artifacts are aggregated only after both jobs
|
|
||||||
and the combined updater feed pass.
|
|
||||||
|
|||||||
@@ -28,15 +28,7 @@ bash scripts/onlyoffice-dev-up.sh --rotate-secret
|
|||||||
|
|
||||||
轮换会强制重建相关容器,已签发但尚未使用的短时预览配置会立即失效。生产环境不使用该自动生成流程,仍必须由部署密钥管理系统显式提供密钥。
|
轮换会强制重建相关容器,已签发但尚未使用的短时预览配置会立即失效。生产环境不使用该自动生成流程,仍必须由部署密钥管理系统显式提供密钥。
|
||||||
|
|
||||||
可用 `ONLYOFFICE_IMAGE` 覆盖默认的 `onlyoffice/documentserver:9.4.0.1`。默认派生镜像只额外增加 Noto Sans CJK/Noto Serif CJK;其他字体仅继承自获准使用的上游镜像。
|
可用 `ONLYOFFICE_IMAGE` 覆盖默认的 `onlyoffice/documentserver:9.4.0.1`。默认派生镜像只增加 Noto Sans CJK/Noto Serif CJK,不包含微软字体。
|
||||||
|
|
||||||
## 字体许可边界
|
|
||||||
|
|
||||||
项目只在派生镜像中额外安装可再分发的 Noto CJK 字体,不打包、复制、下载或自动注入部署方提供的宋体、Times New Roman 等专有字体。文档指定了未安装字体时,ONLYOFFICE 会使用可用字体替代,版式可能产生差异。
|
|
||||||
|
|
||||||
部署方不得将来源不明或没有服务器部署及再分发许可的字体放入项目目录、Docker 构建上下文或 `onlyoffice_data` 卷。确需增加其他字体时,应先取得相应授权并独立完成合规评估;当前安装和更新脚本不提供专有字体注入入口。
|
|
||||||
|
|
||||||
`ONLYOFFICE_IMAGE` 上游基础镜像可能自带其他字体;其内容和许可不属于项目字体注入流程。分发派生镜像前仍应单独审查获准使用的上游镜像,不能以本项目已移除自定义字体作为上游字体合规结论。
|
|
||||||
|
|
||||||
## 配置边界
|
## 配置边界
|
||||||
|
|
||||||
|
|||||||
@@ -45,12 +45,8 @@
|
|||||||
5. 最后一位编辑者退出后,状态 2 回调产生最终修订并推进文件代次;下一次编辑使用新的 `document.key`。
|
5. 最后一位编辑者退出后,状态 2 回调产生最终修订并推进文件代次;下一次编辑使用新的 `document.key`。
|
||||||
6. 重复回调通过指纹幂等处理;旧代次回调不会覆盖当前文件。
|
6. 重复回调通过指纹幂等处理;旧代次回调不会覆盖当前文件。
|
||||||
|
|
||||||
状态 2、3 或 4 的最终回调结束当前编辑生命周期,已关闭会话不得重新激活或复用原 `document.key`。数据库仍标记为 `ACTIVE`、但已接收过回调且 Document Server 的 `info` 命令确认没有在线编辑者时,同样按中断会话退役;刚签发但尚未收到首次连接回调的 key 保留 15 秒连接宽限,避免并发配置请求误判。对于保存错误或这类中断会话,再次打开文件时后端通过 ONLYOFFICE `getForgotten` 命令检查服务器是否保留了备份副本;存在备份时,校验 Office 包结构后写入一条“服务器备份恢复”修订,再推进文件代次并使用新的 `document.key`。正常的无修改关闭只推进代次,不做不必要的备份查询。如果文档服务器没有备份,则从 CTMS 最后一次确认保存的修订开始新代次。恢复服务暂时不可用或返回的备份无效时阻止打开并提示重试,避免反复打开未持久化副本或静默丢弃仍可恢复的数据。状态 7 仅表示本次强制保存失败,不终止仍在进行的共同编辑会话。
|
|
||||||
|
|
||||||
内部内容和回调接口不经过 Nginx 公网入口,只接受 `AuthorizationJwt`。回调结果文件仅允许从配置的 Document Server 内部源获取,禁止重定向、凭据 URL 和任意主机。
|
内部内容和回调接口不经过 Nginx 公网入口,只接受 `AuthorizationJwt`。回调结果文件仅允许从配置的 Document Server 内部源获取,禁止重定向、凭据 URL 和任意主机。
|
||||||
|
|
||||||
安装和更新流程在重启服务前使用 ONLYOFFICE `info` 命令核实数据库中 `ACTIVE` 会话的真实在线用户。仍有编辑者时部署会在服务重启前终止,并只显示文件名和在线人数;应通知用户退出编辑器并等待最终保存后重试。数据库中的陈旧 `ACTIVE` 记录若已不在 Document Server 中存在,不会误阻塞部署。不要通过强制重启或删除 ONLYOFFICE 数据卷绕过检查。
|
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
标准开发安装会直接启动 ONLYOFFICE:
|
标准开发安装会直接启动 ONLYOFFICE:
|
||||||
@@ -64,7 +60,6 @@ bash scripts/install.sh dev
|
|||||||
关键配置:
|
关键配置:
|
||||||
|
|
||||||
- `ONLYOFFICE_ENABLED`
|
- `ONLYOFFICE_ENABLED`
|
||||||
- `FRONTEND_PUBLIC_URL`(必须与用户访问 CTMS 的 origin 一致;安装脚本会同步为 `--base-url`)
|
|
||||||
- `ONLYOFFICE_JWT_SECRET`
|
- `ONLYOFFICE_JWT_SECRET`
|
||||||
- `ONLYOFFICE_INTERNAL_URL`
|
- `ONLYOFFICE_INTERNAL_URL`
|
||||||
- `ONLYOFFICE_STORAGE_BASE_URL`
|
- `ONLYOFFICE_STORAGE_BASE_URL`
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
VITE_RUNTIME_ENV=production
|
VITE_RUNTIME_ENV=production
|
||||||
VITE_ALLOW_INSECURE_DEV_LOGIN=false
|
VITE_ALLOW_INSECURE_DEV_LOGIN=false
|
||||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||||
# Default CTMS origin embedded in Desktop builds; users can override it in Desktop settings.
|
|
||||||
VITE_DESKTOP_SERVER_URL=https://ctms.example.com
|
|
||||||
# Set to 8888 when Vite HMR is accessed through the Docker nginx dev entry.
|
# Set to 8888 when Vite HMR is accessed through the Docker nginx dev entry.
|
||||||
VITE_HMR_CLIENT_PORT=
|
VITE_HMR_CLIENT_PORT=
|
||||||
VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3
|
VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"defaultPlatformSigningMode": "signed",
|
|
||||||
"updaterSigningRequired": true,
|
|
||||||
"unsignedPlatformExceptions": [
|
|
||||||
{
|
|
||||||
"version": "0.1.0",
|
|
||||||
"status": "approved",
|
|
||||||
"macos": "ad-hoc",
|
|
||||||
"windows": "unsigned",
|
|
||||||
"distribution": "controlled",
|
|
||||||
"macosLocalExactTagFallback": true,
|
|
||||||
"windowsDelivery": "deferred-same-tag",
|
|
||||||
"updaterFeedActivation": "after-combined-platform-verification",
|
|
||||||
"githubReleaseAssetProfile": "installer-and-checksum-only",
|
|
||||||
"stagedEvidenceRetention": "private-until-updater-publish",
|
|
||||||
"updaterArtifactPublishTarget": "anonymous-https-origin",
|
|
||||||
"approvedOn": "2026-07-17",
|
|
||||||
"reason": "The v0.1.0 release owner cannot currently provide Apple Developer signing/notarization credentials or an organization Windows code-signing certificate, and hosted Windows build capacity is temporarily unavailable."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
CTMS DESKTOP PLATFORM SIGNING WARNING
|
|
||||||
|
|
||||||
This release is distributed under the version-scoped v0.1.0 platform-signing exception.
|
|
||||||
|
|
||||||
- The macOS application uses an ad-hoc signature and is not Apple-notarized.
|
|
||||||
- The Windows application and installer have no Authenticode signature or RFC 3161 timestamp.
|
|
||||||
- macOS Gatekeeper and Windows SmartScreen warnings are expected.
|
|
||||||
- Tauri updater artifacts remain cryptographically signed and must pass updater feed verification.
|
|
||||||
- Platform availability may be staged; production latest.json remains withheld until macOS and Windows artifacts from the same tag pass combined verification.
|
|
||||||
- Install only after verifying the release tag, source commit, SHA256SUMS.txt, and DESKTOP-RELEASE-PROVENANCE.json through a trusted channel.
|
|
||||||
|
|
||||||
This exception does not apply to later versions unless they are separately approved in desktop-release-policy.json.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
CTMS v0.1.0 WINDOWS RELEASE PENDING
|
|
||||||
|
|
||||||
This GitHub Release is the approved first stage of the v0.1.0 Desktop distribution.
|
|
||||||
|
|
||||||
- macOS Universal artifacts are available and use an ad-hoc platform signature.
|
|
||||||
- Windows x64 NSIS artifacts are not yet available.
|
|
||||||
- Windows must later be built from the same immutable v0.1.0 tag and Git commit.
|
|
||||||
- Production latest.json is intentionally withheld until Windows NotSigned artifacts and the combined updater feed pass verification.
|
|
||||||
- Do not interpret this staged release as a complete cross-platform automatic-update release.
|
|
||||||
@@ -15,10 +15,7 @@
|
|||||||
"desktop:build": "tauri build",
|
"desktop:build": "tauri build",
|
||||||
"desktop:build:app": "tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}' --bundles app",
|
"desktop:build:app": "tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}' --bundles app",
|
||||||
"desktop:build:macos-release": "tauri build --target universal-apple-darwin --bundles app,dmg",
|
"desktop:build:macos-release": "tauri build --target universal-apple-darwin --bundles app,dmg",
|
||||||
"desktop:build:macos-unsigned-release": "tauri build --target universal-apple-darwin --bundles app,dmg --config '{\"bundle\":{\"macOS\":{\"signingIdentity\":\"-\"}}}'",
|
|
||||||
"desktop:build:windows-release": "tauri build --bundles nsis",
|
"desktop:build:windows-release": "tauri build --bundles nsis",
|
||||||
"desktop:release-policy:check": "node scripts/resolve-desktop-release-policy.mjs",
|
|
||||||
"desktop:release-provenance:create": "node scripts/create-desktop-release-provenance.mjs",
|
|
||||||
"desktop:update-feed:create": "node scripts/create-desktop-update-feed.mjs",
|
"desktop:update-feed:create": "node scripts/create-desktop-update-feed.mjs",
|
||||||
"desktop:bundle:dmg": "tauri build --bundles dmg",
|
"desktop:bundle:dmg": "tauri build --bundles dmg",
|
||||||
"desktop:update-feed:check": "node scripts/verify-desktop-update-feed.mjs",
|
"desktop:update-feed:check": "node scripts/verify-desktop-update-feed.mjs",
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
||||||
import { dirname, resolve } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
|
|
||||||
|
|
||||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
|
||||||
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const value = (name) => {
|
|
||||||
const index = args.indexOf(name);
|
|
||||||
return index >= 0 ? args[index + 1] : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const policy = await loadDesktopReleasePolicy();
|
|
||||||
const resolution = resolveDesktopReleasePolicy(packageInfo.version, policy);
|
|
||||||
const tag = value("--tag") || process.env.GITHUB_REF_NAME;
|
|
||||||
const commit = value("--commit") || process.env.GITHUB_SHA;
|
|
||||||
const macosSigning = value("--macos-signing");
|
|
||||||
const windowsSigning = value("--windows-signing");
|
|
||||||
const releaseStage = value("--stage") || "complete";
|
|
||||||
const outputPath = resolve(
|
|
||||||
frontendDir,
|
|
||||||
value("--output") || "src-tauri/target/desktop-release-feed/DESKTOP-RELEASE-PROVENANCE.json",
|
|
||||||
);
|
|
||||||
|
|
||||||
const failures = [];
|
|
||||||
const assert = (condition, message) => {
|
|
||||||
if (!condition) failures.push(message);
|
|
||||||
};
|
|
||||||
|
|
||||||
assert(tag === `v${packageInfo.version}`, `Release provenance tag must be v${packageInfo.version}.`);
|
|
||||||
assert(/^[0-9a-f]{40}$/i.test(commit || ""), "Release provenance commit must be a full 40-character SHA.");
|
|
||||||
assert(macosSigning === resolution.macosSigning, `macOS signing mode must be ${resolution.macosSigning}.`);
|
|
||||||
assert(windowsSigning === resolution.windowsSigning, `Windows signing mode must be ${resolution.windowsSigning}.`);
|
|
||||||
assert(["complete", "macos-first"].includes(releaseStage), "Release provenance stage must be complete or macos-first.");
|
|
||||||
if (releaseStage === "macos-first") {
|
|
||||||
assert(
|
|
||||||
resolution.macosLocalExactTagFallback === true && resolution.windowsDelivery === "deferred-same-tag",
|
|
||||||
`v${packageInfo.version} does not approve a staged local macOS-first release.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failures.length > 0) {
|
|
||||||
throw new Error(`Desktop release provenance creation failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const provenance = {
|
|
||||||
schemaVersion: 1,
|
|
||||||
product: "CTMS Desktop",
|
|
||||||
version: packageInfo.version,
|
|
||||||
tag,
|
|
||||||
commit,
|
|
||||||
platformSigningMode: resolution.platformSigningMode,
|
|
||||||
platformSigning: {
|
|
||||||
macos: macosSigning,
|
|
||||||
windows: windowsSigning,
|
|
||||||
},
|
|
||||||
updaterSigning: "required-and-verified",
|
|
||||||
artifactLabel: resolution.artifactLabel,
|
|
||||||
distribution: resolution.distribution,
|
|
||||||
stage: releaseStage,
|
|
||||||
platformAvailability:
|
|
||||||
releaseStage === "macos-first"
|
|
||||||
? { macos: "available", windows: "pending-same-tag" }
|
|
||||||
: { macos: "available", windows: "available" },
|
|
||||||
updaterFeedActivation:
|
|
||||||
releaseStage === "macos-first" ? "withheld-pending-combined-verification" : "combined-platform-verification-required",
|
|
||||||
warnings: resolution.warningRequired
|
|
||||||
? [
|
|
||||||
"macOS is ad-hoc signed and not Apple-notarized; Gatekeeper warnings are expected.",
|
|
||||||
"Windows is not Authenticode-signed or RFC 3161 timestamped; SmartScreen warnings are expected.",
|
|
||||||
"Verify the release tag, commit, updater signatures, and SHA256SUMS.txt through a trusted channel before installation.",
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
await mkdir(dirname(outputPath), { recursive: true });
|
|
||||||
await writeFile(outputPath, `${JSON.stringify(provenance, null, 2)}\n`);
|
|
||||||
console.log(`Desktop release provenance created at ${outputPath}`);
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
|
||||||
export const desktopReleasePolicyPath = resolve(frontendDir, "desktop-release-policy.json");
|
|
||||||
|
|
||||||
const semverPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/;
|
|
||||||
|
|
||||||
const validatePolicy = (policy) => {
|
|
||||||
const failures = [];
|
|
||||||
const assert = (condition, message) => {
|
|
||||||
if (!condition) failures.push(message);
|
|
||||||
};
|
|
||||||
|
|
||||||
assert(policy?.schemaVersion === 1, "desktop release policy schemaVersion must be 1.");
|
|
||||||
assert(
|
|
||||||
policy?.defaultPlatformSigningMode === "signed",
|
|
||||||
"desktop release policy must default platform signing to signed.",
|
|
||||||
);
|
|
||||||
assert(policy?.updaterSigningRequired === true, "desktop release policy must always require updater signing.");
|
|
||||||
assert(
|
|
||||||
Array.isArray(policy?.unsignedPlatformExceptions),
|
|
||||||
"desktop release policy unsignedPlatformExceptions must be an array.",
|
|
||||||
);
|
|
||||||
|
|
||||||
const versions = new Set();
|
|
||||||
for (const exception of policy?.unsignedPlatformExceptions || []) {
|
|
||||||
const prefix = `unsigned platform exception ${exception?.version || "<missing>"}`;
|
|
||||||
assert(semverPattern.test(exception?.version || ""), `${prefix} must use an exact X.Y.Z version.`);
|
|
||||||
assert(!versions.has(exception?.version), `${prefix} is duplicated.`);
|
|
||||||
versions.add(exception?.version);
|
|
||||||
assert(exception?.status === "approved", `${prefix} must have status approved.`);
|
|
||||||
assert(exception?.macos === "ad-hoc", `${prefix} must constrain macOS to ad-hoc signing.`);
|
|
||||||
assert(exception?.windows === "unsigned", `${prefix} must constrain Windows to unsigned.`);
|
|
||||||
assert(exception?.distribution === "controlled", `${prefix} must constrain distribution to controlled.`);
|
|
||||||
if (exception?.version === "0.1.0") {
|
|
||||||
assert(exception?.macosLocalExactTagFallback === true, `${prefix} must explicitly approve the local exact-tag macOS fallback.`);
|
|
||||||
assert(exception?.windowsDelivery === "deferred-same-tag", `${prefix} must defer Windows only from the same tag.`);
|
|
||||||
assert(
|
|
||||||
exception?.updaterFeedActivation === "after-combined-platform-verification",
|
|
||||||
`${prefix} must withhold the production updater feed until both platforms are verified.`,
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
exception?.githubReleaseAssetProfile === "installer-and-checksum-only",
|
|
||||||
`${prefix} must keep the user-facing GitHub Release limited to installers and their checksum manifest.`,
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
exception?.stagedEvidenceRetention === "private-until-updater-publish",
|
|
||||||
`${prefix} must retain updater artifacts and release evidence privately until updater publication.`,
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
exception?.updaterArtifactPublishTarget === "anonymous-https-origin",
|
|
||||||
`${prefix} must publish updater artifacts to an anonymous HTTPS origin.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert(/^\d{4}-\d{2}-\d{2}$/.test(exception?.approvedOn || ""), `${prefix} must record an approval date.`);
|
|
||||||
assert(
|
|
||||||
typeof exception?.reason === "string" && exception.reason.trim().length >= 30,
|
|
||||||
`${prefix} must record a substantive reason.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failures.length > 0) {
|
|
||||||
throw new Error(failures.join("\n"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const loadDesktopReleasePolicy = async () => {
|
|
||||||
const policy = JSON.parse(await readFile(desktopReleasePolicyPath, "utf8"));
|
|
||||||
validatePolicy(policy);
|
|
||||||
return policy;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resolveDesktopReleasePolicy = (version, policy) => {
|
|
||||||
if (!semverPattern.test(version || "")) {
|
|
||||||
throw new Error(`Desktop release version must use exact X.Y.Z semver; found ${version || "<missing>"}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const exception = policy.unsignedPlatformExceptions.find(
|
|
||||||
(candidate) => candidate.version === version && candidate.status === "approved",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!exception) {
|
|
||||||
return {
|
|
||||||
version,
|
|
||||||
platformSigningMode: "signed",
|
|
||||||
macosSigning: "apple-developer",
|
|
||||||
windowsSigning: "authenticode",
|
|
||||||
artifactLabel: "SIGNED-PLATFORM",
|
|
||||||
distribution: "formal",
|
|
||||||
warningRequired: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
version,
|
|
||||||
platformSigningMode: "unsigned-exception",
|
|
||||||
macosSigning: exception.macos,
|
|
||||||
windowsSigning: exception.windows,
|
|
||||||
artifactLabel: "UNSIGNED-PLATFORM",
|
|
||||||
distribution: exception.distribution,
|
|
||||||
warningRequired: true,
|
|
||||||
macosLocalExactTagFallback: exception.macosLocalExactTagFallback === true,
|
|
||||||
windowsDelivery: exception.windowsDelivery,
|
|
||||||
updaterFeedActivation: exception.updaterFeedActivation,
|
|
||||||
githubReleaseAssetProfile: exception.githubReleaseAssetProfile,
|
|
||||||
stagedEvidenceRetention: exception.stagedEvidenceRetention,
|
|
||||||
updaterArtifactPublishTarget: exception.updaterArtifactPublishTarget,
|
|
||||||
exception,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { appendFile, readFile } from "node:fs/promises";
|
|
||||||
import { resolve } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
|
|
||||||
|
|
||||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
|
||||||
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const value = (name) => {
|
|
||||||
const index = args.indexOf(name);
|
|
||||||
return index >= 0 ? args[index + 1] : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const policy = await loadDesktopReleasePolicy();
|
|
||||||
const resolution = resolveDesktopReleasePolicy(packageInfo.version, policy);
|
|
||||||
const expectedTag = `v${packageInfo.version}`;
|
|
||||||
|
|
||||||
if (args.includes("--require-tag")) {
|
|
||||||
if (process.env.GITHUB_REF_TYPE !== "tag" || process.env.GITHUB_REF_NAME !== expectedTag) {
|
|
||||||
throw new Error(
|
|
||||||
`Desktop release policy must be resolved from tag ${expectedTag}; found ${process.env.GITHUB_REF_TYPE || "<missing>"} ${process.env.GITHUB_REF_NAME || "<missing>"}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputs = {
|
|
||||||
version: resolution.version,
|
|
||||||
platform_signing_mode: resolution.platformSigningMode,
|
|
||||||
macos_signing: resolution.macosSigning,
|
|
||||||
windows_signing: resolution.windowsSigning,
|
|
||||||
artifact_label: resolution.artifactLabel,
|
|
||||||
distribution: resolution.distribution,
|
|
||||||
warning_required: String(resolution.warningRequired),
|
|
||||||
updater_signing_required: String(policy.updaterSigningRequired),
|
|
||||||
};
|
|
||||||
|
|
||||||
const githubOutput = value("--github-output");
|
|
||||||
if (githubOutput) {
|
|
||||||
await appendFile(githubOutput, `${Object.entries(outputs).map(([key, output]) => `${key}=${output}`).join("\n")}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Desktop release policy check passed.");
|
|
||||||
console.log(` version: ${resolution.version}`);
|
|
||||||
console.log(` platform signing mode: ${resolution.platformSigningMode}`);
|
|
||||||
console.log(` macOS: ${resolution.macosSigning}`);
|
|
||||||
console.log(` Windows: ${resolution.windowsSigning}`);
|
|
||||||
console.log(" updater signing: required");
|
|
||||||
@@ -2,13 +2,10 @@ import { execFileSync } from "node:child_process";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
|
|
||||||
|
|
||||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||||
const rootDir = resolve(frontendDir, "..");
|
const rootDir = resolve(frontendDir, "..");
|
||||||
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
||||||
const desktopReleasePolicy = await loadDesktopReleasePolicy();
|
|
||||||
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
|
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
|
||||||
const env = process.env;
|
const env = process.env;
|
||||||
@@ -16,10 +13,6 @@ const fullShaPattern = /^[0-9a-f]{40}$/i;
|
|||||||
const expectedTag = `v${packageInfo.version}`;
|
const expectedTag = `v${packageInfo.version}`;
|
||||||
const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true";
|
const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true";
|
||||||
const requiresWindowsSigning = env.REQUIRE_WINDOWS_SIGNING === "true";
|
const requiresWindowsSigning = env.REQUIRE_WINDOWS_SIGNING === "true";
|
||||||
const requiresUpdaterSigning = env.REQUIRE_UPDATER_SIGNING === "true";
|
|
||||||
const allowsUnsignedPlatformRelease = env.ALLOW_UNSIGNED_PLATFORM_RELEASE === "true";
|
|
||||||
const desktopPlatformSigningMode = env.DESKTOP_PLATFORM_SIGNING_MODE;
|
|
||||||
const desktopReleasePlatform = env.DESKTOP_RELEASE_PLATFORM;
|
|
||||||
const requiredUpdaterEnv = ["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"];
|
const requiredUpdaterEnv = ["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"];
|
||||||
const requiredMacosEnv = ["APPLE_ID", "APPLE_PASSWORD", "APPLE_TEAM_ID"];
|
const requiredMacosEnv = ["APPLE_ID", "APPLE_PASSWORD", "APPLE_TEAM_ID"];
|
||||||
const requiredWindowsEnv = ["WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD"];
|
const requiredWindowsEnv = ["WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD"];
|
||||||
@@ -45,7 +38,7 @@ const gitMaybe = (args) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const requireEnv = (name) => {
|
const requireEnv = (name) => {
|
||||||
assert(Boolean(env[name]), `${name} must be configured for desktop release readiness.`);
|
assert(Boolean(env[name]), `${name} must be configured for signed desktop release readiness.`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateBaseUrl = () => {
|
const validateBaseUrl = () => {
|
||||||
@@ -70,25 +63,6 @@ const validateBaseUrl = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateClientServerUrl = () => {
|
|
||||||
const raw = env.VITE_DESKTOP_SERVER_URL;
|
|
||||||
requireEnv("VITE_DESKTOP_SERVER_URL");
|
|
||||||
if (!raw) return;
|
|
||||||
|
|
||||||
let url;
|
|
||||||
try {
|
|
||||||
url = new URL(raw);
|
|
||||||
} catch (error) {
|
|
||||||
fail(`VITE_DESKTOP_SERVER_URL is invalid: ${error.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(url.protocol === "https:", "VITE_DESKTOP_SERVER_URL must use HTTPS.");
|
|
||||||
assert(url.username === "" && url.password === "", "VITE_DESKTOP_SERVER_URL must not include credentials.");
|
|
||||||
assert(url.pathname === "/", "VITE_DESKTOP_SERVER_URL must be an origin without a path.");
|
|
||||||
assert(url.search === "" && url.hash === "", "VITE_DESKTOP_SERVER_URL must not include query parameters or fragments.");
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateWindowsTimestampUrl = () => {
|
const validateWindowsTimestampUrl = () => {
|
||||||
const raw = env.WINDOWS_TIMESTAMP_URL;
|
const raw = env.WINDOWS_TIMESTAMP_URL;
|
||||||
requireEnv("WINDOWS_TIMESTAMP_URL");
|
requireEnv("WINDOWS_TIMESTAMP_URL");
|
||||||
@@ -121,54 +95,19 @@ if (headSha && env.VITE_BUILD_COMMIT) {
|
|||||||
assert(env.VITE_BUILD_COMMIT === headSha, "VITE_BUILD_COMMIT must match the current release commit.");
|
assert(env.VITE_BUILD_COMMIT === headSha, "VITE_BUILD_COMMIT must match the current release commit.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert(
|
||||||
|
requiresMacosSigning || requiresWindowsSigning,
|
||||||
|
"Release readiness requires REQUIRE_DESKTOP_SIGNING=true or REQUIRE_WINDOWS_SIGNING=true.",
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
!(requiresMacosSigning && requiresWindowsSigning),
|
!(requiresMacosSigning && requiresWindowsSigning),
|
||||||
"macOS and Windows signing readiness must be checked in their native jobs.",
|
"macOS and Windows signing readiness must be checked in their native jobs.",
|
||||||
);
|
);
|
||||||
assert(
|
|
||||||
desktopReleasePlatform === "macos" || desktopReleasePlatform === "windows",
|
|
||||||
"DESKTOP_RELEASE_PLATFORM must be macos or windows.",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
desktopPlatformSigningMode === desktopReleaseResolution.platformSigningMode,
|
|
||||||
`DESKTOP_PLATFORM_SIGNING_MODE must be ${desktopReleaseResolution.platformSigningMode} for v${packageInfo.version}.`,
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
desktopReleasePolicy.updaterSigningRequired === true && requiresUpdaterSigning,
|
|
||||||
"Release readiness requires REQUIRE_UPDATER_SIGNING=true; updater signing cannot be disabled.",
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const name of requiredUpdaterEnv) {
|
for (const name of requiredUpdaterEnv) {
|
||||||
requireEnv(name);
|
requireEnv(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (desktopPlatformSigningMode === "signed") {
|
|
||||||
assert(!allowsUnsignedPlatformRelease, "Signed releases must not enable ALLOW_UNSIGNED_PLATFORM_RELEASE.");
|
|
||||||
assert(
|
|
||||||
(desktopReleasePlatform === "macos" && requiresMacosSigning && !requiresWindowsSigning) ||
|
|
||||||
(desktopReleasePlatform === "windows" && requiresWindowsSigning && !requiresMacosSigning),
|
|
||||||
"Signed readiness must enable only the matching native platform signing requirement.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (desktopPlatformSigningMode === "unsigned-exception") {
|
|
||||||
assert(
|
|
||||||
desktopReleaseResolution.platformSigningMode === "unsigned-exception",
|
|
||||||
`v${packageInfo.version} has no approved unsigned platform release exception.`,
|
|
||||||
);
|
|
||||||
assert(allowsUnsignedPlatformRelease, "The approved exception requires ALLOW_UNSIGNED_PLATFORM_RELEASE=true.");
|
|
||||||
assert(
|
|
||||||
!requiresMacosSigning && !requiresWindowsSigning,
|
|
||||||
"Unsigned platform readiness must not claim Apple or Windows platform signing.",
|
|
||||||
);
|
|
||||||
if (desktopReleasePlatform === "macos") {
|
|
||||||
assert(process.platform === "darwin", "The macOS ad-hoc readiness check must run on macOS.");
|
|
||||||
}
|
|
||||||
if (desktopReleasePlatform === "windows") {
|
|
||||||
assert(process.platform === "win32", "The unsigned Windows readiness check must run on Windows.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requiresMacosSigning) {
|
if (requiresMacosSigning) {
|
||||||
assert(process.platform === "darwin", "Signed macOS desktop release readiness must run on macOS.");
|
assert(process.platform === "darwin", "Signed macOS desktop release readiness must run on macOS.");
|
||||||
for (const name of requiredMacosEnv) {
|
for (const name of requiredMacosEnv) {
|
||||||
@@ -192,7 +131,6 @@ if (requiresWindowsSigning) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validateBaseUrl();
|
validateBaseUrl();
|
||||||
validateClientServerUrl();
|
|
||||||
|
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
console.error(`Desktop release readiness check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
console.error(`Desktop release readiness check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const verifyTauriConfig = async () => {
|
|||||||
assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution.");
|
assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution.");
|
||||||
assert(
|
assert(
|
||||||
tauriConfig.bundle?.createUpdaterArtifacts === true,
|
tauriConfig.bundle?.createUpdaterArtifacts === true,
|
||||||
"Tauri must create signed updater artifacts for formal desktop release builds.",
|
"Tauri must create updater artifacts for signed desktop release builds.",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80,
|
typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80,
|
||||||
@@ -387,28 +387,12 @@ const verifyUpdaterBoundary = async () => {
|
|||||||
assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials.");
|
assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials.");
|
||||||
};
|
};
|
||||||
|
|
||||||
const verifyDesktopServerConfigurationBoundary = async () => {
|
|
||||||
const source = await readFile(resolve(sourceDir, "runtime/desktopServerConfig.ts"), "utf8");
|
|
||||||
assert(
|
|
||||||
source.includes("import.meta.env.VITE_DESKTOP_SERVER_URL"),
|
|
||||||
"Desktop server defaults must come from VITE_DESKTOP_SERVER_URL.",
|
|
||||||
);
|
|
||||||
assert(!source.includes("ctms.huapont.cn"), "The production CTMS origin must not be hard-coded in Desktop runtime source.");
|
|
||||||
assert(
|
|
||||||
source.includes("getStoredDesktopServerUrl() || getDefaultDesktopServerUrl()"),
|
|
||||||
"A manually stored Desktop server URL must override the build-time default.",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const verifyWorkflowGates = async () => {
|
const verifyWorkflowGates = async () => {
|
||||||
const packageInfo = await readJson(resolve(frontendDir, "package.json"));
|
const packageInfo = await readJson(resolve(frontendDir, "package.json"));
|
||||||
assert(packageInfo.engines?.node === ">=22.13.0", "package.json must require Node.js >=22.13.0.");
|
assert(packageInfo.engines?.node === ">=22.13.0", "package.json must require Node.js >=22.13.0.");
|
||||||
const requiredScripts = [
|
const requiredScripts = [
|
||||||
"desktop:build:macos-release",
|
"desktop:build:macos-release",
|
||||||
"desktop:build:macos-unsigned-release",
|
|
||||||
"desktop:build:windows-release",
|
"desktop:build:windows-release",
|
||||||
"desktop:release-policy:check",
|
|
||||||
"desktop:release-provenance:create",
|
|
||||||
"desktop:update-feed:create",
|
"desktop:update-feed:create",
|
||||||
"desktop:update-feed:check",
|
"desktop:update-feed:check",
|
||||||
"desktop:release-readiness:check",
|
"desktop:release-readiness:check",
|
||||||
@@ -419,47 +403,6 @@ const verifyWorkflowGates = async () => {
|
|||||||
assert(Boolean(packageInfo.scripts?.[script]), `package.json must define ${script}.`);
|
assert(Boolean(packageInfo.scripts?.[script]), `package.json must define ${script}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const provenanceSource = await readFile(resolve(frontendDir, "scripts/create-desktop-release-provenance.mjs"), "utf8");
|
|
||||||
assert(provenanceSource.includes('"macos-first"'), "Release provenance must support the approved v0.1.0 macOS-first stage.");
|
|
||||||
assert(
|
|
||||||
provenanceSource.includes("withheld-pending-combined-verification"),
|
|
||||||
"Staged macOS provenance must record that updater feed activation is withheld.",
|
|
||||||
);
|
|
||||||
const windowsPendingNotice = await readFile(resolve(frontendDir, "desktop-release-windows-pending.txt"), "utf8");
|
|
||||||
assert(
|
|
||||||
windowsPendingNotice.includes("same immutable v0.1.0 tag") && windowsPendingNotice.includes("latest.json is intentionally withheld"),
|
|
||||||
"The staged v0.1.0 private release evidence must include an explicit Windows-pending and updater-feed notice.",
|
|
||||||
);
|
|
||||||
|
|
||||||
const releasePolicy = await readJson(resolve(frontendDir, "desktop-release-policy.json"));
|
|
||||||
assert(releasePolicy.schemaVersion === 1, "Desktop release policy schemaVersion must be 1.");
|
|
||||||
assert(
|
|
||||||
releasePolicy.defaultPlatformSigningMode === "signed",
|
|
||||||
"Desktop release policy must default future versions to signed platform releases.",
|
|
||||||
);
|
|
||||||
assert(releasePolicy.updaterSigningRequired === true, "Desktop release policy must require updater signing.");
|
|
||||||
const currentUnsignedException = releasePolicy.unsignedPlatformExceptions?.find(
|
|
||||||
(exception) => exception.version === packageInfo.version,
|
|
||||||
);
|
|
||||||
if (packageInfo.version === "0.1.0") {
|
|
||||||
assert(Boolean(currentUnsignedException), "Desktop release policy must record the approved v0.1.0 exception.");
|
|
||||||
assert(
|
|
||||||
currentUnsignedException?.macosLocalExactTagFallback === true &&
|
|
||||||
currentUnsignedException?.windowsDelivery === "deferred-same-tag" &&
|
|
||||||
currentUnsignedException?.updaterFeedActivation === "after-combined-platform-verification" &&
|
|
||||||
currentUnsignedException?.githubReleaseAssetProfile === "installer-and-checksum-only" &&
|
|
||||||
currentUnsignedException?.stagedEvidenceRetention === "private-until-updater-publish" &&
|
|
||||||
currentUnsignedException?.updaterArtifactPublishTarget === "anonymous-https-origin",
|
|
||||||
"The v0.1.0 exception must constrain local macOS staging, deferred Windows delivery, visible GitHub assets, private evidence retention, and updater publication.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (currentUnsignedException) {
|
|
||||||
assert(
|
|
||||||
currentUnsignedException.macos === "ad-hoc" && currentUnsignedException.windows === "unsigned",
|
|
||||||
`The v${packageInfo.version} exception must use macOS ad-hoc and unsigned Windows modes.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
|
const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
|
||||||
const requiredCommands = [
|
const requiredCommands = [
|
||||||
"npm run version:check",
|
"npm run version:check",
|
||||||
@@ -478,7 +421,6 @@ const verifyWorkflowGates = async () => {
|
|||||||
}
|
}
|
||||||
assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL.");
|
assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL.");
|
||||||
assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT.");
|
assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT.");
|
||||||
assert(workflow.includes("VITE_DESKTOP_SERVER_URL"), "Client quality gates workflow must inject VITE_DESKTOP_SERVER_URL.");
|
|
||||||
assert(workflow.match(/node-version: "22\.13"/g)?.length === 2, "Client quality gates must use Node.js 22.13 for Web and Desktop jobs.");
|
assert(workflow.match(/node-version: "22\.13"/g)?.length === 2, "Client quality gates must use Node.js 22.13 for Web and Desktop jobs.");
|
||||||
|
|
||||||
const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8");
|
const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8");
|
||||||
@@ -487,18 +429,7 @@ const verifyWorkflowGates = async () => {
|
|||||||
"runs-on: windows-latest",
|
"runs-on: windows-latest",
|
||||||
"REQUIRE_DESKTOP_SIGNING",
|
"REQUIRE_DESKTOP_SIGNING",
|
||||||
"REQUIRE_WINDOWS_SIGNING",
|
"REQUIRE_WINDOWS_SIGNING",
|
||||||
"REQUIRE_UPDATER_SIGNING",
|
|
||||||
"ALLOW_UNSIGNED_PLATFORM_RELEASE",
|
|
||||||
"DESKTOP_PLATFORM_SIGNING_MODE",
|
|
||||||
"desktop:release-policy:check",
|
|
||||||
"desktop:build:macos-unsigned-release",
|
|
||||||
"Signature=adhoc",
|
|
||||||
'"NotSigned"',
|
|
||||||
"UNSIGNED-PLATFORM",
|
|
||||||
"DESKTOP-RELEASE-PROVENANCE.json",
|
|
||||||
"--require-provenance",
|
|
||||||
"TAURI_SIGNING_PRIVATE_KEY",
|
"TAURI_SIGNING_PRIVATE_KEY",
|
||||||
"VITE_DESKTOP_SERVER_URL",
|
|
||||||
"APPLE_ID",
|
"APPLE_ID",
|
||||||
"APPLE_PASSWORD",
|
"APPLE_PASSWORD",
|
||||||
"APPLE_TEAM_ID",
|
"APPLE_TEAM_ID",
|
||||||
@@ -532,7 +463,6 @@ const verifyWorkflowGates = async () => {
|
|||||||
"runs-on: windows-latest",
|
"runs-on: windows-latest",
|
||||||
"VITE_BUILD_CHANNEL",
|
"VITE_BUILD_CHANNEL",
|
||||||
"VITE_BUILD_COMMIT",
|
"VITE_BUILD_COMMIT",
|
||||||
"VITE_DESKTOP_SERVER_URL",
|
|
||||||
"npm run release:env:check",
|
"npm run release:env:check",
|
||||||
"npm run version:check",
|
"npm run version:check",
|
||||||
"npm run runtime:check",
|
"npm run runtime:check",
|
||||||
@@ -591,7 +521,6 @@ await verifySourceSafety();
|
|||||||
await verifyNotificationBoundary();
|
await verifyNotificationBoundary();
|
||||||
await verifySessionBoundary();
|
await verifySessionBoundary();
|
||||||
await verifyUpdaterBoundary();
|
await verifyUpdaterBoundary();
|
||||||
await verifyDesktopServerConfigurationBoundary();
|
|
||||||
await verifyOnlyOfficeBoundary();
|
await verifyOnlyOfficeBoundary();
|
||||||
await verifyWorkflowGates();
|
await verifyWorkflowGates();
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ import { access, readFile } from "node:fs/promises";
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { basename, resolve } from "node:path";
|
import { basename, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
|
|
||||||
|
|
||||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||||
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
||||||
const desktopReleasePolicy = await loadDesktopReleasePolicy();
|
|
||||||
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
|
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
@@ -24,11 +21,6 @@ const feedPath = resolve(
|
|||||||
);
|
);
|
||||||
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
|
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
|
||||||
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
|
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
|
||||||
const requiresProvenance = args.includes("--require-provenance");
|
|
||||||
const provenancePath =
|
|
||||||
optionValue("--provenance") ||
|
|
||||||
process.env.DESKTOP_RELEASE_PROVENANCE ||
|
|
||||||
(artifactDir ? resolve(artifactDir, "DESKTOP-RELEASE-PROVENANCE.json") : undefined);
|
|
||||||
const requiredPlatforms = new Set([
|
const requiredPlatforms = new Set([
|
||||||
"darwin-aarch64",
|
"darwin-aarch64",
|
||||||
"darwin-x86_64",
|
"darwin-x86_64",
|
||||||
@@ -102,56 +94,6 @@ const assertManifestEntries = async (checksums) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const verifyProvenance = async (checksums) => {
|
|
||||||
if (!requiresProvenance) return;
|
|
||||||
if (!provenancePath) {
|
|
||||||
fail("Desktop release provenance path is required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let provenance;
|
|
||||||
try {
|
|
||||||
provenance = JSON.parse(await readFile(provenancePath, "utf8"));
|
|
||||||
} catch (error) {
|
|
||||||
fail(`Cannot read desktop release provenance ${provenancePath}: ${error.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(provenance.schemaVersion === 1, "Desktop release provenance schemaVersion must be 1.");
|
|
||||||
assert(provenance.version === packageInfo.version, "Desktop release provenance version must match the package version.");
|
|
||||||
assert(provenance.tag === `v${packageInfo.version}`, "Desktop release provenance tag must match the package version.");
|
|
||||||
assert(/^[0-9a-f]{40}$/i.test(provenance.commit || ""), "Desktop release provenance must contain a full commit SHA.");
|
|
||||||
assert(
|
|
||||||
provenance.platformSigningMode === desktopReleaseResolution.platformSigningMode,
|
|
||||||
`Desktop release provenance platformSigningMode must be ${desktopReleaseResolution.platformSigningMode}.`,
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
provenance.platformSigning?.macos === desktopReleaseResolution.macosSigning,
|
|
||||||
`Desktop release provenance macOS signing must be ${desktopReleaseResolution.macosSigning}.`,
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
provenance.platformSigning?.windows === desktopReleaseResolution.windowsSigning,
|
|
||||||
`Desktop release provenance Windows signing must be ${desktopReleaseResolution.windowsSigning}.`,
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
provenance.updaterSigning === "required-and-verified",
|
|
||||||
"Desktop release provenance must record required-and-verified updater signing.",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
provenance.artifactLabel === desktopReleaseResolution.artifactLabel,
|
|
||||||
`Desktop release provenance artifactLabel must be ${desktopReleaseResolution.artifactLabel}.`,
|
|
||||||
);
|
|
||||||
if (desktopReleaseResolution.warningRequired) {
|
|
||||||
assert(
|
|
||||||
Array.isArray(provenance.warnings) && provenance.warnings.length >= 2,
|
|
||||||
"Unsigned platform release provenance must include installation trust warnings.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (artifactDir) {
|
|
||||||
await assertChecksum(checksums, provenancePath, "desktop release provenance");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let feed;
|
let feed;
|
||||||
try {
|
try {
|
||||||
feed = JSON.parse(await readFile(feedPath, "utf8"));
|
feed = JSON.parse(await readFile(feedPath, "utf8"));
|
||||||
@@ -162,7 +104,6 @@ try {
|
|||||||
if (feed) {
|
if (feed) {
|
||||||
const checksums = await readChecksumManifest();
|
const checksums = await readChecksumManifest();
|
||||||
await assertManifestEntries(checksums);
|
await assertManifestEntries(checksums);
|
||||||
await verifyProvenance(checksums);
|
|
||||||
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
|
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
|
||||||
const platforms = feed.platforms || {};
|
const platforms = feed.platforms || {};
|
||||||
const darwinArm = platforms["darwin-aarch64"];
|
const darwinArm = platforms["darwin-aarch64"];
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ import { execFileSync } from "node:child_process";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { loadDesktopReleasePolicy, resolveDesktopReleasePolicy } from "./desktop-release-policy.mjs";
|
|
||||||
|
|
||||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||||
const rootDir = resolve(frontendDir, "..");
|
const rootDir = resolve(frontendDir, "..");
|
||||||
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
||||||
const desktopReleasePolicy = await loadDesktopReleasePolicy();
|
|
||||||
const desktopReleaseResolution = resolveDesktopReleasePolicy(packageInfo.version, desktopReleasePolicy);
|
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
|
||||||
const allowedChannels = new Set(["dev", "main", "release", "local"]);
|
const allowedChannels = new Set(["dev", "main", "release", "local"]);
|
||||||
@@ -23,36 +20,6 @@ const isTagBuild = env.GITHUB_REF_TYPE === "tag";
|
|||||||
const isReleaseBuild = env.RELEASE_BUILD === "true" || isTagBuild || channel === "release";
|
const isReleaseBuild = env.RELEASE_BUILD === "true" || isTagBuild || channel === "release";
|
||||||
const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true";
|
const requiresMacosSigning = env.REQUIRE_DESKTOP_SIGNING === "true";
|
||||||
const requiresWindowsSigning = env.REQUIRE_WINDOWS_SIGNING === "true";
|
const requiresWindowsSigning = env.REQUIRE_WINDOWS_SIGNING === "true";
|
||||||
const requiresUpdaterSigning = env.REQUIRE_UPDATER_SIGNING === "true";
|
|
||||||
const allowsUnsignedPlatformRelease = env.ALLOW_UNSIGNED_PLATFORM_RELEASE === "true";
|
|
||||||
const desktopPlatformSigningMode = env.DESKTOP_PLATFORM_SIGNING_MODE;
|
|
||||||
const desktopReleasePlatform = env.DESKTOP_RELEASE_PLATFORM;
|
|
||||||
const isNativeDesktopRelease = Boolean(
|
|
||||||
desktopReleasePlatform ||
|
|
||||||
desktopPlatformSigningMode ||
|
|
||||||
requiresMacosSigning ||
|
|
||||||
requiresWindowsSigning ||
|
|
||||||
requiresUpdaterSigning,
|
|
||||||
);
|
|
||||||
|
|
||||||
const validateDesktopServerUrl = () => {
|
|
||||||
const raw = env.VITE_DESKTOP_SERVER_URL;
|
|
||||||
requireEnv("VITE_DESKTOP_SERVER_URL");
|
|
||||||
if (!raw) return;
|
|
||||||
|
|
||||||
let url;
|
|
||||||
try {
|
|
||||||
url = new URL(raw);
|
|
||||||
} catch (error) {
|
|
||||||
fail(`VITE_DESKTOP_SERVER_URL is invalid: ${error.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(url.protocol === "https:", "VITE_DESKTOP_SERVER_URL must use HTTPS.");
|
|
||||||
assert(url.username === "" && url.password === "", "VITE_DESKTOP_SERVER_URL must not include credentials.");
|
|
||||||
assert(url.pathname === "/", "VITE_DESKTOP_SERVER_URL must be an origin without a path.");
|
|
||||||
assert(url.search === "" && url.hash === "", "VITE_DESKTOP_SERVER_URL must not include query parameters or fragments.");
|
|
||||||
};
|
|
||||||
|
|
||||||
const fail = (message) => failures.push(message);
|
const fail = (message) => failures.push(message);
|
||||||
const assert = (condition, message) => {
|
const assert = (condition, message) => {
|
||||||
@@ -60,7 +27,7 @@ const assert = (condition, message) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const requireEnv = (name) => {
|
const requireEnv = (name) => {
|
||||||
assert(Boolean(env[name]), `${name} must be configured for this desktop release build.`);
|
assert(Boolean(env[name]), `${name} must be configured for signed desktop release builds.`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const gitHead = () => {
|
const gitHead = () => {
|
||||||
@@ -80,7 +47,6 @@ assert(allowedChannels.has(channel), `VITE_BUILD_CHANNEL must be one of ${[...al
|
|||||||
if (isCi || isReleaseBuild) {
|
if (isCi || isReleaseBuild) {
|
||||||
assert(channel !== "local", "CI and release builds must inject VITE_BUILD_CHANNEL.");
|
assert(channel !== "local", "CI and release builds must inject VITE_BUILD_CHANNEL.");
|
||||||
assert(fullShaPattern.test(commit), "CI and release builds must inject a full 40-character VITE_BUILD_COMMIT.");
|
assert(fullShaPattern.test(commit), "CI and release builds must inject a full 40-character VITE_BUILD_COMMIT.");
|
||||||
validateDesktopServerUrl();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (env.GITHUB_SHA) {
|
if (env.GITHUB_SHA) {
|
||||||
@@ -108,59 +74,13 @@ assert(
|
|||||||
"macOS and Windows signing requirements must be checked in their native jobs.",
|
"macOS and Windows signing requirements must be checked in their native jobs.",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isNativeDesktopRelease) {
|
|
||||||
assert(isReleaseBuild, "Native desktop release settings may only be used for release builds.");
|
|
||||||
assert(
|
|
||||||
desktopReleasePolicy.updaterSigningRequired === true && requiresUpdaterSigning,
|
|
||||||
"Formal desktop releases must set REQUIRE_UPDATER_SIGNING=true; updater signing cannot be disabled.",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
desktopPlatformSigningMode === desktopReleaseResolution.platformSigningMode,
|
|
||||||
`DESKTOP_PLATFORM_SIGNING_MODE must be ${desktopReleaseResolution.platformSigningMode} for v${packageInfo.version}.`,
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
desktopReleasePlatform === "macos" || desktopReleasePlatform === "windows",
|
|
||||||
"DESKTOP_RELEASE_PLATFORM must be macos or windows for native desktop release builds.",
|
|
||||||
);
|
|
||||||
if (isCi) {
|
|
||||||
assert(isTagBuild, "Native desktop release candidate builds in CI must run from a release tag.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requiresUpdaterSigning) {
|
|
||||||
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
|
|
||||||
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (desktopPlatformSigningMode === "signed") {
|
|
||||||
assert(!allowsUnsignedPlatformRelease, "Signed releases must not enable ALLOW_UNSIGNED_PLATFORM_RELEASE.");
|
|
||||||
assert(
|
|
||||||
(desktopReleasePlatform === "macos" && requiresMacosSigning && !requiresWindowsSigning) ||
|
|
||||||
(desktopReleasePlatform === "windows" && requiresWindowsSigning && !requiresMacosSigning),
|
|
||||||
"Signed native release jobs must enable only their matching platform signing requirement.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (desktopPlatformSigningMode === "unsigned-exception") {
|
|
||||||
assert(
|
|
||||||
desktopReleaseResolution.platformSigningMode === "unsigned-exception",
|
|
||||||
`v${packageInfo.version} has no approved unsigned platform release exception.`,
|
|
||||||
);
|
|
||||||
assert(allowsUnsignedPlatformRelease, "The approved exception requires ALLOW_UNSIGNED_PLATFORM_RELEASE=true.");
|
|
||||||
assert(
|
|
||||||
!requiresMacosSigning && !requiresWindowsSigning,
|
|
||||||
"Unsigned platform exceptions must not claim Apple or Windows platform signing.",
|
|
||||||
);
|
|
||||||
if (desktopReleasePlatform === "macos") {
|
|
||||||
assert(process.platform === "darwin", "The macOS ad-hoc release exception must run on macOS.");
|
|
||||||
}
|
|
||||||
if (desktopReleasePlatform === "windows") {
|
|
||||||
assert(process.platform === "win32", "The unsigned Windows release exception must run on Windows.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requiresMacosSigning) {
|
if (requiresMacosSigning) {
|
||||||
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS.");
|
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS.");
|
||||||
|
if (isCi) {
|
||||||
|
assert(isTagBuild, "Signed desktop release candidate builds in CI must run from a release tag.");
|
||||||
|
}
|
||||||
|
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
|
||||||
|
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
|
||||||
requireEnv("APPLE_ID");
|
requireEnv("APPLE_ID");
|
||||||
requireEnv("APPLE_PASSWORD");
|
requireEnv("APPLE_PASSWORD");
|
||||||
requireEnv("APPLE_TEAM_ID");
|
requireEnv("APPLE_TEAM_ID");
|
||||||
@@ -175,6 +95,11 @@ if (requiresMacosSigning) {
|
|||||||
|
|
||||||
if (requiresWindowsSigning) {
|
if (requiresWindowsSigning) {
|
||||||
assert(process.platform === "win32", "Signed Windows desktop release builds must run on Windows.");
|
assert(process.platform === "win32", "Signed Windows desktop release builds must run on Windows.");
|
||||||
|
if (isCi) {
|
||||||
|
assert(isTagBuild, "Signed Windows desktop release candidate builds in CI must run from a release tag.");
|
||||||
|
}
|
||||||
|
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
|
||||||
|
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
|
||||||
requireEnv("WINDOWS_CERTIFICATE");
|
requireEnv("WINDOWS_CERTIFICATE");
|
||||||
requireEnv("WINDOWS_CERTIFICATE_PASSWORD");
|
requireEnv("WINDOWS_CERTIFICATE_PASSWORD");
|
||||||
requireEnv("WINDOWS_TIMESTAMP_URL");
|
requireEnv("WINDOWS_TIMESTAMP_URL");
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import {
|
|
||||||
fetchCollaborationFile, fetchCollaborationMembers, fetchCollaborationCandidates,
|
|
||||||
upsertCollaborationMember, removeCollaborationMember, fetchCollaborationShareLink,
|
|
||||||
updateCollaborationShareLink, updateCollaborationFile, fetchCollaborationEditRequests,
|
|
||||||
resolveCollaborationEditRequest, transferCollaborationOwnership,
|
|
||||||
} from "./collaboration";
|
|
||||||
import {
|
|
||||||
fetchLedger, fetchLedgerMembers, fetchLedgerCandidates, setLedgerMember, removeLedgerMember,
|
|
||||||
fetchLedgerShareLink, updateLedgerShareLink, updateLedgerAccessSettings, fetchLedgerEditRequests,
|
|
||||||
resolveLedgerEditRequest, transferLedgerOwnership,
|
|
||||||
} from "./ledgers";
|
|
||||||
import type { CollaborationCandidate, CollaborationFile } from "../types/collaboration";
|
|
||||||
|
|
||||||
export type CollaborationAccessTarget = { scope: "project"; studyId: string } | { scope: "ledger" };
|
|
||||||
export type CollaborationAccessFile = Pick<CollaborationFile,
|
|
||||||
"id" | "title" | "owner_id" | "owner_name" | "file_type" | "status" | "generation" |
|
|
||||||
"can_manage" | "can_transfer_ownership" | "allow_export" | "allow_edit_request" | "allow_sheet_structure_edit"
|
|
||||||
>;
|
|
||||||
export type CollaborationAccessRole = "VIEWER" | "EDITOR" | "MANAGER";
|
|
||||||
export interface CollaborationAccessMember {
|
|
||||||
user_id: string;
|
|
||||||
full_name: string;
|
|
||||||
email: string;
|
|
||||||
role: CollaborationAccessRole;
|
|
||||||
is_active?: boolean;
|
|
||||||
protected_label?: string | null;
|
|
||||||
}
|
|
||||||
export type CollaborationAccessCandidate = CollaborationCandidate;
|
|
||||||
type AccessPatch = Partial<Pick<CollaborationAccessFile, "allow_export" | "allow_edit_request" | "allow_sheet_structure_edit">>;
|
|
||||||
|
|
||||||
export const createCollaborationAccessApi = (target: CollaborationAccessTarget) => {
|
|
||||||
const project = target.scope === "project" ? target.studyId : null;
|
|
||||||
return {
|
|
||||||
file: (id: string) => project !== null ? fetchCollaborationFile(project, id) : fetchLedger(id),
|
|
||||||
members: (id: string) => project !== null ? fetchCollaborationMembers(project, id) : fetchLedgerMembers(id),
|
|
||||||
candidates: (id: string) => project !== null ? fetchCollaborationCandidates(project) : fetchLedgerCandidates(id),
|
|
||||||
setMember: (id: string, payload: { user_id: string; role: CollaborationAccessRole }) => {
|
|
||||||
if (project === null) return setLedgerMember(id, payload.user_id, payload.role);
|
|
||||||
if (payload.role === "VIEWER") return Promise.reject(new Error("项目协作不支持此授权角色"));
|
|
||||||
return upsertCollaborationMember(project, id, { ...payload, role: payload.role });
|
|
||||||
},
|
|
||||||
removeMember: (id: string, userId: string) => project !== null
|
|
||||||
? removeCollaborationMember(project, id, userId) : removeLedgerMember(id, userId),
|
|
||||||
share: (id: string) => project !== null ? fetchCollaborationShareLink(project, id) : fetchLedgerShareLink(id),
|
|
||||||
updateShare: (id: string, payload: Parameters<typeof updateCollaborationShareLink>[2]) => project !== null
|
|
||||||
? updateCollaborationShareLink(project, id, payload) : updateLedgerShareLink(id, payload),
|
|
||||||
updateFile: (file: CollaborationAccessFile, payload: AccessPatch) => project !== null
|
|
||||||
? updateCollaborationFile(project, file.id, payload)
|
|
||||||
: updateLedgerAccessSettings(file.id, { ...payload, generation: file.generation }),
|
|
||||||
requests: (id: string) => project !== null ? fetchCollaborationEditRequests(project, id) : fetchLedgerEditRequests(id),
|
|
||||||
resolveRequest: (id: string, requestId: string, status: "APPROVED" | "REJECTED") => project !== null
|
|
||||||
? resolveCollaborationEditRequest(project, id, requestId, status) : resolveLedgerEditRequest(id, requestId, status),
|
|
||||||
transfer: (id: string, userId: string) => project !== null
|
|
||||||
? transferCollaborationOwnership(project, id, userId) : transferLedgerOwnership(id, userId),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { CollaborationFile } from "../types/collaboration";
|
|
||||||
import type { CollaborationAccessTarget } from "./collaborationAccess";
|
|
||||||
import { fetchCollaborationFile, fetchCollaborationRevisions, updateCollaborationRevision, restoreCollaborationRevision } from "./collaboration";
|
|
||||||
import { fetchLedger, fetchLedgerRevisions, updateLedgerRevision, restoreLedgerRevision } from "./ledgers";
|
|
||||||
|
|
||||||
export type CollaborationHistoryFile = Pick<CollaborationFile,
|
|
||||||
"id" | "title" | "owner_name" | "file_type" | "extension" | "folder_id" | "status" | "generation" |
|
|
||||||
"current_revision_id" | "current_revision_no" | "current_revision_file_size" | "current_revision_mime_type" |
|
|
||||||
"current_revision_created_at" | "can_edit" | "can_manage" | "can_export"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export const createCollaborationHistoryApi = (target: CollaborationAccessTarget) => ({
|
|
||||||
file: (id: string) => target.scope === "ledger" ? fetchLedger(id) : fetchCollaborationFile(target.studyId, id),
|
|
||||||
revisions: (id: string) => target.scope === "ledger" ? fetchLedgerRevisions(id) : fetchCollaborationRevisions(target.studyId, id),
|
|
||||||
name: (id: string, revisionId: string, payload: { change_summary: string }) => target.scope === "ledger"
|
|
||||||
? updateLedgerRevision(id, revisionId, payload) : updateCollaborationRevision(target.studyId, id, revisionId, payload),
|
|
||||||
restore: (file: CollaborationHistoryFile, revisionId: string) => target.scope === "ledger"
|
|
||||||
? restoreLedgerRevision(file.id, revisionId, file.generation) : restoreCollaborationRevision(target.studyId, file.id, revisionId),
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "./axios";
|
|
||||||
import type { Ledger, LedgerCandidate, LedgerMember, LedgerRole, LedgerSettings } from "../types/ledger";
|
|
||||||
import type { CollaborationEditorConfig, CollaborationRevision, CollaborationEditRequest, CollaborationShareLink } from "../types/collaboration";
|
|
||||||
import type { updateCollaborationShareLink } from "./collaboration";
|
|
||||||
import type { OnlyOfficePreviewConfig } from "../types/onlyoffice";
|
|
||||||
|
|
||||||
const base = "/api/v1/ledgers";
|
|
||||||
const online = { cache: false, disableRequestDedupe: true, disableNetworkRetry: true, suppressErrorMessage: true } as const;
|
|
||||||
|
|
||||||
export const fetchLedgers = () => apiGet<Ledger[]>(base, online);
|
|
||||||
export const initializeLedgers = () => apiPost<Ledger[]>(`${base}/initialize`, {}, online);
|
|
||||||
export const fetchLedger = (id: string) => apiGet<Ledger>(`${base}/${id}`, online);
|
|
||||||
export const updateLedger = (id: string, payload: LedgerSettings) => apiPatch<Ledger>(`${base}/${id}`, payload, online);
|
|
||||||
export const importLedger = (id: string, file: File, generation: number) => {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append("file", file);
|
|
||||||
form.append("generation", String(generation));
|
|
||||||
return apiPost<Ledger>(`${base}/${id}/import`, form, online);
|
|
||||||
};
|
|
||||||
export const fetchLedgerMembers = (id: string) => apiGet<LedgerMember[]>(`${base}/${id}/members`, online);
|
|
||||||
export const fetchLedgerCandidates = (id: string, keyword = "") =>
|
|
||||||
apiGet<LedgerCandidate[]>(`${base}/${id}/candidates`, { ...online, params: { keyword } });
|
|
||||||
export const setLedgerMember = (id: string, userId: string, role: LedgerRole) =>
|
|
||||||
apiPut(`${base}/${id}/members`, { user_id: userId, role }, online);
|
|
||||||
export const removeLedgerMember = (id: string, userId: string) => apiDelete(`${base}/${id}/members/${userId}`, online);
|
|
||||||
export const updateLedgerAccessSettings = (id: string, payload: {
|
|
||||||
generation: number; allow_export?: boolean; allow_edit_request?: boolean; allow_sheet_structure_edit?: boolean;
|
|
||||||
}) => apiPatch<Ledger>(`${base}/${id}/access-settings`, payload, online);
|
|
||||||
export const fetchLedgerShareLink = (id: string) => apiGet<CollaborationShareLink>(`${base}/${id}/share-link`, online);
|
|
||||||
export const updateLedgerShareLink = (id: string, payload: Parameters<typeof updateCollaborationShareLink>[2]) =>
|
|
||||||
apiPut<CollaborationShareLink>(`${base}/${id}/share-link`, payload, online);
|
|
||||||
export const createLedgerEditRequest = (id: string) => apiPost<CollaborationEditRequest>(`${base}/${id}/edit-requests`, {}, online);
|
|
||||||
export const fetchLedgerEditRequests = (id: string) => apiGet<CollaborationEditRequest[]>(`${base}/${id}/edit-requests`, online);
|
|
||||||
export const resolveLedgerEditRequest = (id: string, requestId: string, status: "APPROVED" | "REJECTED") =>
|
|
||||||
apiPost<CollaborationEditRequest>(`${base}/${id}/edit-requests/${requestId}/resolve`, { status }, online);
|
|
||||||
export const transferLedgerOwnership = (id: string, userId: string) =>
|
|
||||||
apiPost<Ledger>(`${base}/${id}/transfer-ownership`, { new_owner_id: userId }, online);
|
|
||||||
export const fetchLedgerEditorConfig = (id: string) => apiGet<CollaborationEditorConfig>(`${base}/${id}/editor-config`, online);
|
|
||||||
export const checkLedgerDownload = (id: string, fileType: string) =>
|
|
||||||
apiPost(`${base}/${id}/downloads`, { file_type: fileType }, online);
|
|
||||||
export const fetchLedgerRevisions = (id: string) => apiGet<CollaborationRevision[]>(`${base}/${id}/revisions`, online);
|
|
||||||
export const updateLedgerRevision = (id: string, revisionId: string, payload: { change_summary: string }) =>
|
|
||||||
apiPatch<CollaborationRevision>(`${base}/${id}/revisions/${revisionId}`, payload, online);
|
|
||||||
export const downloadLedgerRevision = (id: string, revisionId: string) =>
|
|
||||||
apiGet<Blob>(`${base}/${id}/revisions/${revisionId}/download`, { ...online, responseType: "blob" });
|
|
||||||
export const restoreLedgerRevision = (id: string, revisionId: string, generation: number) =>
|
|
||||||
apiPost<CollaborationRevision>(`${base}/${id}/revisions/${revisionId}/restore`, { generation }, online);
|
|
||||||
export const fetchLedgerRevisionPreview = (id: string, revisionId: string) =>
|
|
||||||
apiGet<OnlyOfficePreviewConfig>(`${base}/${id}/revisions/${revisionId}/preview-config`, online);
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<template>
|
|
||||||
<span class="entry-count-badge">
|
|
||||||
<span class="entry-count-badge__dot" aria-hidden="true"></span>
|
|
||||||
<span><slot /></span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.entry-count-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 6px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(59, 130, 246, 0.08);
|
|
||||||
color: #1d4ed8;
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-count-badge__dot {
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global([data-ctms-theme="dark"]) .entry-count-badge {
|
|
||||||
background: rgba(59, 130, 246, 0.12);
|
|
||||||
color: #60a5fa;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -47,12 +47,11 @@ describe("PermissionIpLocations", () => {
|
|||||||
expect(source).not.toContain('all: "近 90 天"');
|
expect(source).not.toContain('all: "近 90 天"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps fullscreen animation sharp without dirty-rect artifacts or extra canvas layers", () => {
|
it("keeps fullscreen animation sharp without multiplying large canvas layers", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
expect(source).toContain('v-if="!fullscreenVisible"');
|
expect(source).toContain('v-if="!fullscreenVisible"');
|
||||||
expect(source).toContain("useDirtyRect: false");
|
expect(source).toContain("useDirtyRect: true");
|
||||||
expect(source).not.toContain("useDirtyRect: true");
|
|
||||||
expect(source).toContain("Math.min(getDevicePixelRatio(), 2)");
|
expect(source).toContain("Math.min(getDevicePixelRatio(), 2)");
|
||||||
expect(source).not.toContain("getDevicePixelRatio() * 1.5");
|
expect(source).not.toContain("getDevicePixelRatio() * 1.5");
|
||||||
expect(source).not.toContain("zlevel:");
|
expect(source).not.toContain("zlevel:");
|
||||||
|
|||||||
@@ -223,9 +223,8 @@ const mapLoadError = ref("");
|
|||||||
const mapReady = ref(false);
|
const mapReady = ref(false);
|
||||||
const lastUpdatedAt = ref<Date | null>(null);
|
const lastUpdatedAt = ref<Date | null>(null);
|
||||||
const getDevicePixelRatio = () => (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1);
|
const getDevicePixelRatio = () => (typeof window === "undefined" ? 1 : window.devicePixelRatio || 1);
|
||||||
// Animated lines/effectScatter leave cleared rectangles over the geo layer when dirty-rect rendering is enabled.
|
const chartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2), useDirtyRect: true };
|
||||||
const chartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2), useDirtyRect: false };
|
const fullscreenChartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2), useDirtyRect: true };
|
||||||
const fullscreenChartInitOptions = { devicePixelRatio: Math.min(getDevicePixelRatio(), 2), useDirtyRect: false };
|
|
||||||
const chartUpdateOptions = { notMerge: true };
|
const chartUpdateOptions = { notMerge: true };
|
||||||
const chartAutoresize = { throttle: 120 };
|
const chartAutoresize = { throttle: 120 };
|
||||||
const fullscreenVisible = ref(false);
|
const fullscreenVisible = ref(false);
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { flushPromises, mount } from "@vue/test-utils";
|
|
||||||
import AccessDialog from "./CollaborationAccessDialog.vue";
|
|
||||||
import { createCollaborationAccessApi, type CollaborationAccessFile, type CollaborationAccessTarget } from "../../api/collaborationAccess";
|
|
||||||
|
|
||||||
const api = vi.hoisted(() => ({
|
|
||||||
file: vi.fn(), members: vi.fn(), candidates: vi.fn(), requests: vi.fn(), share: vi.fn(),
|
|
||||||
setMember: vi.fn(), removeMember: vi.fn(), updateFile: vi.fn(), updateShare: vi.fn(), resolveRequest: vi.fn(), transfer: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("../../api/collaborationAccess", () => ({ createCollaborationAccessApi: vi.fn(() => api) }));
|
|
||||||
vi.mock("../../runtime", () => ({ clientRuntime: { apiBaseUrl: () => "http://localhost:8888" } }));
|
|
||||||
vi.mock("../../composables/useProjectNotifications", () => ({ notifyProjectNotificationsChanged: vi.fn() }));
|
|
||||||
vi.mock("../../utils/apiErrorMessage", () => ({ getApiErrorMessage: async (_error: unknown, fallback: string) => fallback }));
|
|
||||||
const file: CollaborationAccessFile = {
|
|
||||||
id: "file-a", title: "台账.xlsx", owner_id: "owner", owner_name: "Owner", file_type: "cell",
|
|
||||||
status: "ACTIVE", generation: 4, can_manage: true, can_transfer_ownership: true,
|
|
||||||
allow_export: false, allow_edit_request: true, allow_sheet_structure_edit: false,
|
|
||||||
};
|
|
||||||
const owner = { user_id: "owner", full_name: "Owner", email: "owner@example.com", role: "MANAGER", protected_label: "所有者" };
|
|
||||||
const candidate = { user_id: "candidate", full_name: "Candidate", email: "candidate@example.com", role_in_study: "系统账号", can_be_editor: true, can_be_manager: true };
|
|
||||||
const share = { enabled: false, has_password: false, access_mode: "VIEW", expiry_policy: "SEVEN_DAYS", share_path: "/collaboration/share", share_token: null };
|
|
||||||
let wrapper: ReturnType<typeof mount> | undefined;
|
|
||||||
const render = (target: CollaborationAccessTarget = { scope: "ledger" }) => {
|
|
||||||
wrapper = mount(AccessDialog, { props: { file, target }, global: {
|
|
||||||
directives: { loading: () => {} },
|
|
||||||
stubs: {
|
|
||||||
ElDialog: { props: ["modelValue", "title"], template: '<div v-if="modelValue" role="dialog"><slot name="header" />{{ title }}<slot /><slot name="footer" /></div>' },
|
|
||||||
ElButton: { template: '<button><slot /></button>' }, ElAvatar: { template: '<span><slot /></span>' },
|
|
||||||
ElTag: { template: '<span><slot /></span>' }, ElIcon: { template: '<span><slot /></span>' },
|
|
||||||
ElEmpty: true, ElInput: true,
|
|
||||||
ElSelect: { props: ["modelValue"], emits: ["update:modelValue", "change"], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value); $emit(\'change\', $event.target.value)"><slot /></select>' },
|
|
||||||
ElOption: { props: ["label", "value"], template: '<option :value="value">{{ label }}</option>' },
|
|
||||||
ElSwitch: { props: ["modelValue", "disabled"], emits: ["update:modelValue", "change"], template: '<input type="checkbox" :checked="modelValue" :disabled="disabled" @change="$emit(\'update:modelValue\', $event.target.checked); $emit(\'change\', $event.target.checked)" />' },
|
|
||||||
},
|
|
||||||
} });
|
|
||||||
return wrapper;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("shared collaboration access dialog", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
api.file.mockResolvedValue({ data: { ...file } });
|
|
||||||
api.members.mockResolvedValue({ data: [owner] });
|
|
||||||
api.candidates.mockResolvedValue({ data: [candidate] });
|
|
||||||
api.requests.mockResolvedValue({ data: [] });
|
|
||||||
api.share.mockResolvedValue({ data: share });
|
|
||||||
api.setMember.mockResolvedValue({});
|
|
||||||
});
|
|
||||||
afterEach(() => { wrapper?.unmount(); wrapper = undefined; });
|
|
||||||
|
|
||||||
it.each(["ledger", "project"] as const)("uses the same picker with the %s account directory and role boundary", async (scope) => {
|
|
||||||
const target: CollaborationAccessTarget = scope === "project" ? { scope, studyId: "study-a" } : { scope };
|
|
||||||
const page = render(target);
|
|
||||||
await flushPromises();
|
|
||||||
expect(createCollaborationAccessApi).toHaveBeenCalledWith(target);
|
|
||||||
expect(page.get(".access-member-row").text()).toContain("所有者");
|
|
||||||
expect(page.find(".access-member-remove").exists()).toBe(false);
|
|
||||||
expect(page.get('[aria-label="启用匿名访问"]').element).toHaveProperty("checked", false);
|
|
||||||
await page.get(".access-section__manage-button").trigger("click");
|
|
||||||
expect(page.get('[role="listbox"]').attributes("aria-label")).toBe(scope === "ledger" ? "可添加的系统账号" : "可添加的项目成员");
|
|
||||||
const select = page.get('[aria-label="文件授权角色"]');
|
|
||||||
expect(select.find('option[value="VIEWER"]').exists()).toBe(scope === "ledger");
|
|
||||||
if (scope === "ledger") await select.setValue("VIEWER");
|
|
||||||
await page.get(".member-picker-contact").trigger("click");
|
|
||||||
await page.findAll(".member-picker-selection__footer button").find((button) => button.text() === "确定")!.trigger("click");
|
|
||||||
await flushPromises();
|
|
||||||
expect(api.setMember).toHaveBeenCalledWith("file-a", { user_id: "candidate", role: scope === "ledger" ? "VIEWER" : "EDITOR" });
|
|
||||||
expect(page.emitted("changed")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the refreshed generation after a share change before saving file permissions", async () => {
|
|
||||||
const page = render();
|
|
||||||
await flushPromises();
|
|
||||||
api.updateShare.mockResolvedValue({ data: { ...share, enabled: true, share_token: "test-token" } });
|
|
||||||
api.file.mockResolvedValue({ data: { ...file, generation: 5 } });
|
|
||||||
await page.get('[aria-label="启用匿名访问"]').setValue(true);
|
|
||||||
await flushPromises();
|
|
||||||
expect(api.updateShare).toHaveBeenCalledWith("file-a", expect.objectContaining({ enabled: true, password_mode: "KEEP" }));
|
|
||||||
await page.get(".access-setting-navigation").trigger("click");
|
|
||||||
api.updateFile.mockResolvedValue({ data: { ...file, generation: 6, allow_export: true } });
|
|
||||||
await page.get(".access-permission-row input").setValue(true);
|
|
||||||
await flushPromises();
|
|
||||||
expect(api.updateFile).toHaveBeenCalledWith(expect.objectContaining({ generation: 5 }), { allow_export: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes access controls when a removed manager no longer has access", async () => {
|
|
||||||
api.members.mockResolvedValue({ data: [{ ...owner, user_id: "manager", protected_label: null }] });
|
|
||||||
const page = render();
|
|
||||||
await flushPromises();
|
|
||||||
api.removeMember.mockResolvedValue({});
|
|
||||||
api.members.mockRejectedValue({ response: { status: 404 } });
|
|
||||||
await page.get(".access-member-remove").trigger("click");
|
|
||||||
await flushPromises();
|
|
||||||
expect(page.find('[role="dialog"]').exists()).toBe(false);
|
|
||||||
expect(page.emitted("changed")).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,104 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { flushPromises, mount } from "@vue/test-utils";
|
|
||||||
import HistoryDialog from "./CollaborationHistoryDialog.vue";
|
|
||||||
import type { CollaborationHistoryFile } from "../../api/collaborationHistory";
|
|
||||||
import { downloadLedgerRevision } from "../../api/ledgers";
|
|
||||||
import { prepareSaveFile } from "../../runtime";
|
|
||||||
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
|
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({
|
|
||||||
api: { file: vi.fn(), revisions: vi.fn(), name: vi.fn(), restore: vi.fn() },
|
|
||||||
resolve: vi.fn(() => ({ href: "/ledger-preview" })), prompt: vi.fn(), confirm: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("../../api/collaborationHistory", () => ({ createCollaborationHistoryApi: () => state.api }));
|
|
||||||
vi.mock("../../api/collaboration", () => ({ copyCollaborationRevision: vi.fn(), deleteCollaborationRevision: vi.fn() }));
|
|
||||||
vi.mock("../../api/ledgers", () => ({ downloadLedgerRevision: vi.fn(), importLedger: vi.fn() }));
|
|
||||||
vi.mock("../../runtime", () => ({ prepareSaveFile: vi.fn(), pickFiles: vi.fn() }));
|
|
||||||
vi.mock("../../utils/fileTaskFeedback", () => ({ saveFileWithFeedback: vi.fn() }));
|
|
||||||
vi.mock("../../store/auth", () => ({ useAuthStore: () => ({ user: { id: "u", full_name: "Owner" } }) }));
|
|
||||||
vi.mock("vue-router", () => ({ useRouter: () => ({ resolve: state.resolve }) }));
|
|
||||||
vi.mock("element-plus", () => ({ ElMessage: { error: vi.fn(), success: vi.fn() }, ElMessageBox: { prompt: state.prompt, confirm: state.confirm } }));
|
|
||||||
vi.mock("./CollaborationSaveAsDialog.vue", () => ({ default: { template: "<div />" } }));
|
|
||||||
|
|
||||||
const file: CollaborationHistoryFile = { id: "ledger-a", title: "台账.xlsx", extension: "xlsx", file_type: "cell", status: "ACTIVE", generation: 7, can_manage: true, can_edit: true, can_export: true, current_revision_id: "r2", owner_name: "Owner" };
|
|
||||||
const revisions = [
|
|
||||||
{ id: "r2", revision_no: 2, source: "FORCE_SAVE", created_at: "2026-09-03T01:00:00Z", file_size: 200, mime_type: "application/xlsx", change_summary: "", created_by_name: "Owner" },
|
|
||||||
{ id: "r1", revision_no: 1, source: "IMPORT", created_at: "2026-09-02T01:00:00Z", file_size: 100, mime_type: "application/xlsx", change_summary: "提交版", created_by_name: "Owner" },
|
|
||||||
];
|
|
||||||
let wrapper: ReturnType<typeof mount> | undefined;
|
|
||||||
const render = (overrides = {}, scope: "project" | "ledger" = "ledger") => {
|
|
||||||
const current = { ...file, ...overrides };
|
|
||||||
state.api.file.mockResolvedValue({ data: current });
|
|
||||||
wrapper = mount(HistoryDialog, { props: { file: current, target: scope === "ledger" ? { scope } : { scope, studyId: "study-a" }, canCreate: true }, global: {
|
|
||||||
directives: { loading: () => {} }, stubs: {
|
|
||||||
ElDialog: { props: ["modelValue"], template: '<div v-if="modelValue"><slot name="header"/><slot/></div>' },
|
|
||||||
ElButton: { template: '<button><slot/></button>' }, ElAvatar: { template: '<span><slot/></span>' },
|
|
||||||
ElTag: { template: '<span><slot/></span>' }, ElIcon: { template: '<span><slot/></span>' }, ElTooltip: { template: '<span><slot/></span>' }, ElEmpty: true,
|
|
||||||
ElDropdown: { name: "ElDropdown", emits: ["command"], template: '<div><slot/><slot name="dropdown"/></div>' },
|
|
||||||
ElDropdownMenu: { template: '<div><slot/></div>' }, ElDropdownItem: { template: '<span><slot/></span>' },
|
|
||||||
ElSelect: { props: ["modelValue"], emits: ["update:modelValue"], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value)"><slot/></select>' },
|
|
||||||
ElOption: { props: ["value", "label"], template: '<option :value="value">{{ label }}</option>' },
|
|
||||||
ElSwitch: { props: ["modelValue"], emits: ["update:modelValue"], template: '<input type="checkbox" :checked="modelValue" @change="$emit(\'update:modelValue\', $event.target.checked)"/>' },
|
|
||||||
},
|
|
||||||
} });
|
|
||||||
return wrapper;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("shared project and ledger history", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks(); vi.useFakeTimers();
|
|
||||||
state.api.revisions.mockResolvedValue({ data: revisions.map(item => ({ ...item })) });
|
|
||||||
state.prompt.mockResolvedValue({ value: "复核版" }); state.confirm.mockResolvedValue("confirm");
|
|
||||||
});
|
|
||||||
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.useRealTimers(); vi.restoreAllMocks(); });
|
|
||||||
|
|
||||||
it.each(["project", "ledger"] as const)("filters and names versions in the same %s history UI", async (scope) => {
|
|
||||||
const page = render({}, scope); await flushPromises();
|
|
||||||
expect(page.findAll(".history-day-group")).toHaveLength(2);
|
|
||||||
await page.get(".history-described-filter input").setValue(true);
|
|
||||||
expect(page.findAll(".history-row")).toHaveLength(1);
|
|
||||||
await page.get(".history-source-filter").setValue("FORCE_SAVE");
|
|
||||||
expect(page.findAll(".history-row")).toHaveLength(0);
|
|
||||||
await page.get(".history-described-filter input").setValue(false);
|
|
||||||
state.api.name.mockResolvedValue({ data: { ...revisions[0], change_summary: "复核版" } });
|
|
||||||
await page.findAll(".history-row button").find(button => button.text() === "命名")!.trigger("click");
|
|
||||||
await flushPromises();
|
|
||||||
expect(state.api.name).toHaveBeenCalledWith("ledger-a", "r2", { change_summary: "复核版" });
|
|
||||||
expect(page.get(".history-description").text()).toContain("复核版");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves ledger no-delete and manager-only restore while using the current generation", async () => {
|
|
||||||
const editor = render({ can_manage: false, can_export: false }); await flushPromises();
|
|
||||||
expect(editor.text()).toContain("命名");
|
|
||||||
expect(editor.text()).not.toContain("恢复此版本"); expect(editor.text()).not.toContain("另存为");
|
|
||||||
editor.unmount();
|
|
||||||
const manager = render(); await flushPromises();
|
|
||||||
expect(manager.text()).not.toContain("删除版本");
|
|
||||||
const restored = { ...revisions[0], id: "r3", revision_no: 3, source: "RESTORE" };
|
|
||||||
state.api.restore.mockResolvedValue({ data: restored });
|
|
||||||
state.api.revisions.mockResolvedValue({ data: [restored, ...revisions] });
|
|
||||||
state.api.file.mockResolvedValue({ data: { ...file, generation: 8, current_revision_id: "r3" } });
|
|
||||||
manager.findComponent({ name: "ElDropdown" }).vm.$emit("command", "restore");
|
|
||||||
// Emit the same command as the real row menu with its bound revision.
|
|
||||||
await flushPromises();
|
|
||||||
expect(state.api.restore).toHaveBeenCalledWith(expect.objectContaining({ generation: 7 }), "r1");
|
|
||||||
expect(manager.findAll(".history-row")).toHaveLength(3);
|
|
||||||
expect(manager.get(".history-row.is-current").text()).toContain("历史恢复");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens a global read-only preview and saves a historical XLSX without project context", async () => {
|
|
||||||
const open = vi.spyOn(window, "open").mockReturnValue(null);
|
|
||||||
const page = render(); await flushPromises();
|
|
||||||
await page.findAll(".history-row button").find(button => button.text() === "预览")!.trigger("click");
|
|
||||||
expect(state.resolve).toHaveBeenCalledWith({ name: "LedgerRevisionPreview", params: { fileId: "ledger-a", id: "r2" } });
|
|
||||||
expect(open).toHaveBeenCalledWith("/ledger-preview", "_blank", "noopener,noreferrer");
|
|
||||||
const destination = { kind: "web-download" };
|
|
||||||
vi.mocked(prepareSaveFile).mockResolvedValue(destination as any);
|
|
||||||
const blob = new Blob(["historical bytes"]);
|
|
||||||
vi.mocked(downloadLedgerRevision).mockResolvedValue({ data: blob } as any);
|
|
||||||
await page.findAll(".history-row button").find(button => button.text() === "另存为")!.trigger("click");
|
|
||||||
await flushPromises();
|
|
||||||
expect(downloadLedgerRevision).toHaveBeenCalledWith("ledger-a", "r2");
|
|
||||||
expect(saveFileWithFeedback).toHaveBeenCalledWith(expect.objectContaining({ data: blob, suggestedName: expect.stringMatching(/台账\[.*\]\.xlsx$/) }), expect.anything(), destination);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,553 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-dialog
|
|
||||||
v-model="historyDialogVisible"
|
|
||||||
width="840px"
|
|
||||||
class="collaboration-action-dialog history-dialog"
|
|
||||||
modal-class="history-dialog-overlay"
|
|
||||||
destroy-on-close
|
|
||||||
append-to-body
|
|
||||||
@closed="closeHistory"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div v-if="historyFile" class="history-dialog__title">
|
|
||||||
<span class="history-file-badge" :class="`is-${historyFile.file_type}`">{{ fileTypeLabel(historyFile.file_type) }}</span>
|
|
||||||
<div>
|
|
||||||
<strong>{{ historyFile.title }}</strong>
|
|
||||||
<div class="history-dialog__meta">
|
|
||||||
<span>创建者:{{ historyFile.owner_name || "—" }}</span>
|
|
||||||
<span :title="historyFile.id">文件 ID:{{ historyFile.id.slice(0, 8) }}…</span>
|
|
||||||
<span>文件大小:{{ fileSizeLabel(historyFile.current_revision_file_size) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="history-toolbar">
|
|
||||||
<label class="history-described-filter">
|
|
||||||
<span>仅显示已命名版本</span>
|
|
||||||
<el-switch v-model="historyOnlyDescribed" />
|
|
||||||
</label>
|
|
||||||
<span class="history-toolbar__spacer" />
|
|
||||||
<el-button v-if="target.scope === 'ledger' && canRestore" :disabled="historyLoading || historyActionKey !== null || restoringRevisionId !== null" @click="importNewRevision">导入新版本</el-button>
|
|
||||||
<el-select v-model="historySourceFilter" class="history-source-filter" aria-label="版本来源筛选">
|
|
||||||
<el-option label="全部来源" value="ALL" />
|
|
||||||
<el-option v-for="option in historySourceOptions" :key="option.value" :label="option.label" :value="option.value" />
|
|
||||||
</el-select>
|
|
||||||
<el-tooltip content="刷新历史版本" placement="top">
|
|
||||||
<el-button circle :loading="historyLoading" aria-label="刷新历史版本" @click="refreshHistory()">
|
|
||||||
<el-icon><Refresh /></el-icon>
|
|
||||||
</el-button>
|
|
||||||
</el-tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-loading="historyLoading" class="history-panel">
|
|
||||||
<div class="history-list-header">
|
|
||||||
<span>时间</span>
|
|
||||||
<span>更新者</span>
|
|
||||||
<span>大小</span>
|
|
||||||
<span>版本来源</span>
|
|
||||||
<span>版本名称</span>
|
|
||||||
<span>操作</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="historyRevisionGroups.length" class="history-groups">
|
|
||||||
<section v-for="group in historyRevisionGroups" :key="group.key" class="history-day-group">
|
|
||||||
<h3>{{ group.label }}</h3>
|
|
||||||
<div v-for="row in group.items" :key="row.id" class="history-row" :class="{ 'is-current': row.id === historyFile?.current_revision_id }">
|
|
||||||
<span class="history-time">{{ revisionTimeLabel(row.created_at) }}</span>
|
|
||||||
<span class="history-author">
|
|
||||||
<el-avatar :size="26" :src="row.created_by_avatar_url || undefined">{{ revisionAuthorInitial(row) }}</el-avatar>
|
|
||||||
<span>{{ revisionAuthorName(row) }}</span>
|
|
||||||
</span>
|
|
||||||
<span>{{ formatFileSize(row.file_size) }}</span>
|
|
||||||
<span class="history-source">{{ sourceLabel(row.source) }}</span>
|
|
||||||
<span class="history-description">
|
|
||||||
<span>{{ row.change_summary || "未命名" }}</span>
|
|
||||||
<el-tag v-if="row.id === historyFile?.current_revision_id" size="small" type="info" effect="plain">当前版本</el-tag>
|
|
||||||
</span>
|
|
||||||
<span class="history-row__action">
|
|
||||||
<el-button
|
|
||||||
v-if="historyFile?.can_edit"
|
|
||||||
link
|
|
||||||
type="primary"
|
|
||||||
:loading="historyActionKey === `${row.id}:name`"
|
|
||||||
:disabled="historyActionKey !== null || restoringRevisionId !== null"
|
|
||||||
@click="nameRevision(row)"
|
|
||||||
>命名</el-button>
|
|
||||||
<el-button
|
|
||||||
link
|
|
||||||
type="primary"
|
|
||||||
:disabled="historyActionKey !== null || restoringRevisionId !== null"
|
|
||||||
@click="previewRevision(row)"
|
|
||||||
>预览</el-button>
|
|
||||||
<el-button
|
|
||||||
v-if="canSaveCopy"
|
|
||||||
link
|
|
||||||
type="primary"
|
|
||||||
:loading="historyActionKey === `${row.id}:copy`"
|
|
||||||
:disabled="historyActionKey !== null || restoringRevisionId !== null"
|
|
||||||
@click="saveRevisionAs(row)"
|
|
||||||
>另存为</el-button>
|
|
||||||
<el-dropdown
|
|
||||||
v-if="canRestore && row.id !== historyFile?.current_revision_id"
|
|
||||||
trigger="click"
|
|
||||||
@command="handleRevisionMoreAction($event, row)"
|
|
||||||
>
|
|
||||||
<el-button
|
|
||||||
link
|
|
||||||
class="history-row__more"
|
|
||||||
aria-label="更多版本操作"
|
|
||||||
:loading="restoringRevisionId === row.id"
|
|
||||||
:disabled="historyActionKey !== null || restoringRevisionId !== null"
|
|
||||||
>•••</el-button>
|
|
||||||
<template #dropdown>
|
|
||||||
<el-dropdown-menu>
|
|
||||||
<el-dropdown-item command="restore">恢复此版本</el-dropdown-item>
|
|
||||||
<el-dropdown-item v-if="target.scope === 'project' && historyFile?.can_manage" command="delete" divided>删除版本</el-dropdown-item>
|
|
||||||
</el-dropdown-menu>
|
|
||||||
</template>
|
|
||||||
</el-dropdown>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<el-empty v-else description="没有符合条件的历史版本" :image-size="72" />
|
|
||||||
</div>
|
|
||||||
</el-dialog>
|
|
||||||
|
|
||||||
<CollaborationSaveAsDialog
|
|
||||||
v-if="target.scope === 'project'"
|
|
||||||
v-model="revisionSaveAsDialogVisible"
|
|
||||||
:study-id="target.scope === 'project' ? target.studyId : ''"
|
|
||||||
:initial-title="revisionSaveAsTitle"
|
|
||||||
:initial-folder-id="revisionSaveAsFolderId"
|
|
||||||
:saving="revisionSaveAsSaving"
|
|
||||||
:can-create-folder="canManageFolders"
|
|
||||||
@confirm="submitRevisionSaveAs"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, onBeforeUnmount, onDeactivated, ref } from "vue";
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
|
||||||
import { Refresh } from "@element-plus/icons-vue";
|
|
||||||
import { useAuthStore } from "../../store/auth";
|
|
||||||
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
|
|
||||||
import { formatFileSize } from "../attachments/attachmentUtils";
|
|
||||||
import { copyCollaborationRevision, deleteCollaborationRevision } from "../../api/collaboration";
|
|
||||||
import { downloadLedgerRevision, importLedger } from "../../api/ledgers";
|
|
||||||
import { createCollaborationHistoryApi, type CollaborationHistoryFile } from "../../api/collaborationHistory";
|
|
||||||
import type { CollaborationAccessTarget } from "../../api/collaborationAccess";
|
|
||||||
import type { CollaborationRevision, CollaborationFileType } from "../../types/collaboration";
|
|
||||||
import { prepareSaveFile, pickFiles } from "../../runtime";
|
|
||||||
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
|
|
||||||
import CollaborationSaveAsDialog from "./CollaborationSaveAsDialog.vue";
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ file: CollaborationHistoryFile; target: CollaborationAccessTarget; canCreate?: boolean; canManageFolders?: boolean }>(), { canCreate: false, canManageFolders: false });
|
|
||||||
const emit = defineEmits<{ close: []; changed: []; updated: [file: CollaborationHistoryFile] }>();
|
|
||||||
const auth = useAuthStore();
|
|
||||||
const router = useRouter();
|
|
||||||
const api = createCollaborationHistoryApi(props.target);
|
|
||||||
const canRestore = computed(() => props.target.scope === "ledger"
|
|
||||||
? Boolean(historyFile.value?.can_manage && historyFile.value.status === "ACTIVE") : Boolean(historyFile.value?.can_edit));
|
|
||||||
const canSaveCopy = computed(() => Boolean(historyFile.value?.can_export && (props.target.scope === "ledger" || props.canCreate)));
|
|
||||||
const fileTypeLabel = (value: CollaborationFileType) => ({ word: "W", cell: "X", slide: "P" })[value];
|
|
||||||
const fileSizeLabel = (value?: number | null) => value == null ? "—" : formatFileSize(value);
|
|
||||||
const historyDialogVisible = ref(false);
|
|
||||||
const historyLoading = ref(false);
|
|
||||||
const restoringRevisionId = ref<string | null>(null);
|
|
||||||
const historyActionKey = ref<string | null>(null);
|
|
||||||
const historyOnlyDescribed = ref(false);
|
|
||||||
const historySourceFilter = ref("ALL");
|
|
||||||
const historyFile = ref<CollaborationHistoryFile | null>(null);
|
|
||||||
const revisions = ref<CollaborationRevision[]>([]);
|
|
||||||
const revisionSaveAsDialogVisible = ref(false);
|
|
||||||
const revisionSaveAsTarget = ref<CollaborationRevision | null>(null);
|
|
||||||
const revisionSaveAsFolderId = ref<string | null>(null);
|
|
||||||
const revisionSaveAsTitle = ref("");
|
|
||||||
const revisionSaveAsSaving = ref(false);
|
|
||||||
const historyRefreshTimers: number[] = [];
|
|
||||||
let historyRequestSequence = 0;
|
|
||||||
|
|
||||||
const historySourceOptions = computed(() =>
|
|
||||||
[...new Set(revisions.value.map((item) => item.source))]
|
|
||||||
.map((value) => ({ value, label: sourceLabel(value) }))
|
|
||||||
.sort((left, right) => left.label.localeCompare(right.label, "zh-CN")),
|
|
||||||
);
|
|
||||||
const historyFilteredRevisions = computed(() => revisions.value.filter((item) =>
|
|
||||||
(historySourceFilter.value === "ALL" || item.source === historySourceFilter.value) &&
|
|
||||||
(!historyOnlyDescribed.value || Boolean(item.change_summary?.trim())),
|
|
||||||
));
|
|
||||||
const revisionDateKey = (value: string) => {
|
|
||||||
const date = new Date(value);
|
|
||||||
const pad = (part: number) => String(part).padStart(2, "0");
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
||||||
};
|
|
||||||
const revisionDateLabel = (value: string) => {
|
|
||||||
const date = new Date(value);
|
|
||||||
const weekday = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"][date.getDay()];
|
|
||||||
return `${revisionDateKey(value)} ${weekday}`;
|
|
||||||
};
|
|
||||||
const revisionTimeLabel = (value: string) => {
|
|
||||||
const date = new Date(value);
|
|
||||||
const pad = (part: number) => String(part).padStart(2, "0");
|
|
||||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
||||||
};
|
|
||||||
const historyRevisionGroups = computed(() => {
|
|
||||||
const groups = new Map<string, CollaborationRevision[]>();
|
|
||||||
for (const revision of historyFilteredRevisions.value) {
|
|
||||||
const key = revisionDateKey(revision.created_at);
|
|
||||||
const items = groups.get(key) || [];
|
|
||||||
items.push(revision);
|
|
||||||
groups.set(key, items);
|
|
||||||
}
|
|
||||||
return [...groups.entries()].map(([key, items]) => ({
|
|
||||||
key,
|
|
||||||
label: revisionDateLabel(items[0].created_at),
|
|
||||||
items,
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
const revisionAuthorName = (revision: CollaborationRevision) => {
|
|
||||||
if (revision.created_by_name) return revision.created_by_name;
|
|
||||||
if (revision.created_by && revision.created_by === auth.user?.id) {
|
|
||||||
return auth.user.full_name || auth.user.email || "当前用户";
|
|
||||||
}
|
|
||||||
return revision.created_by ? (props.target.scope === "ledger" ? "系统账号" : "项目成员") : revision.source.startsWith("SHARE_") ? "匿名协作者" : "系统";
|
|
||||||
};
|
|
||||||
const revisionAuthorInitial = (revision: CollaborationRevision) =>
|
|
||||||
revisionAuthorName(revision).trim().slice(0, 1).toUpperCase() || "?";
|
|
||||||
|
|
||||||
function sourceLabel(source: string) {
|
|
||||||
return ({
|
|
||||||
CREATE: "新建",
|
|
||||||
IMPORT: "导入",
|
|
||||||
FORCE_SAVE: "手动保存",
|
|
||||||
SESSION_CLOSE: "自动保存",
|
|
||||||
RESTORE: "历史恢复",
|
|
||||||
COPY: "复制创建",
|
|
||||||
SHARE_FORCE_SAVE: "链接协作保存",
|
|
||||||
SHARE_SESSION_CLOSE: "链接协作关闭",
|
|
||||||
SERVER_RECOVERY: "服务器备份恢复",
|
|
||||||
} as Record<string, string>)[source] || source;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearHistoryRefreshTimers = () => {
|
|
||||||
while (historyRefreshTimers.length) window.clearTimeout(historyRefreshTimers.pop());
|
|
||||||
};
|
|
||||||
|
|
||||||
const scheduleHistoryRefresh = () => {
|
|
||||||
clearHistoryRefreshTimers();
|
|
||||||
// ONLYOFFICE 的会话关闭回调可能晚于用户返回文件库;弹窗打开后继续
|
|
||||||
// 追踪最终自动保存版本,避免用户必须手动点击刷新。
|
|
||||||
for (const delay of [2_500, 12_000]) {
|
|
||||||
historyRefreshTimers.push(window.setTimeout(() => {
|
|
||||||
if (historyDialogVisible.value) void refreshHistory({ silent: true });
|
|
||||||
}, delay));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshHistory = async (options: { silent?: boolean } = {}) => {
|
|
||||||
if (!historyFile.value) return;
|
|
||||||
const fileId = historyFile.value.id;
|
|
||||||
const requestSequence = historyRequestSequence + 1;
|
|
||||||
historyRequestSequence = requestSequence;
|
|
||||||
historyLoading.value = true;
|
|
||||||
try {
|
|
||||||
const [revisionResponse, fileResponse] = await Promise.all([
|
|
||||||
api.revisions(fileId),
|
|
||||||
api.file(fileId),
|
|
||||||
]);
|
|
||||||
if (
|
|
||||||
requestSequence !== historyRequestSequence ||
|
|
||||||
!historyDialogVisible.value ||
|
|
||||||
historyFile.value?.id !== fileId
|
|
||||||
) return;
|
|
||||||
revisions.value = revisionResponse.data;
|
|
||||||
historyFile.value = fileResponse.data;
|
|
||||||
emit("updated", fileResponse.data);
|
|
||||||
} catch (error) {
|
|
||||||
if ([403, 404].includes(Number((error as { response?: { status?: number } })?.response?.status))) {
|
|
||||||
closeHistory();
|
|
||||||
}
|
|
||||||
if (requestSequence === historyRequestSequence && !options.silent) {
|
|
||||||
ElMessage.error(await getApiErrorMessage(error, "修订历史加载失败"));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (requestSequence === historyRequestSequence) historyLoading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openHistory = async (item: CollaborationHistoryFile) => {
|
|
||||||
clearHistoryRefreshTimers();
|
|
||||||
historyFile.value = item;
|
|
||||||
revisions.value = [];
|
|
||||||
historyOnlyDescribed.value = false;
|
|
||||||
historySourceFilter.value = "ALL";
|
|
||||||
historyDialogVisible.value = true;
|
|
||||||
scheduleHistoryRefresh();
|
|
||||||
await refreshHistory();
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeHistory = () => {
|
|
||||||
clearHistoryRefreshTimers();
|
|
||||||
historyRequestSequence += 1;
|
|
||||||
historyLoading.value = false;
|
|
||||||
historyFile.value = null;
|
|
||||||
revisions.value = [];
|
|
||||||
emit("close");
|
|
||||||
};
|
|
||||||
|
|
||||||
const revisionCopyTitle = (revision: CollaborationRevision) => {
|
|
||||||
if (!historyFile.value) return `历史版本[${revisionSaveAsTimeLabel(revision.created_at)}]`;
|
|
||||||
const suffix = `.${historyFile.value.extension}`;
|
|
||||||
const title = historyFile.value.title;
|
|
||||||
const stem = title.toLowerCase().endsWith(suffix) ? title.slice(0, -suffix.length) : title;
|
|
||||||
return `${stem}[${revisionSaveAsTimeLabel(revision.created_at)}]${suffix}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const revisionSaveAsTimeLabel = (value: string) => {
|
|
||||||
const date = new Date(value);
|
|
||||||
const pad = (part: number) => String(part).padStart(2, "0");
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}︰${pad(date.getMinutes())}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const nameRevision = async (revision: CollaborationRevision) => {
|
|
||||||
if (!historyFile.value || historyActionKey.value) return;
|
|
||||||
const fileId = historyFile.value.id;
|
|
||||||
try {
|
|
||||||
const { value } = await ElMessageBox.prompt("请输入版本名称", "命名版本", {
|
|
||||||
inputValue: revision.change_summary || "",
|
|
||||||
inputValidator: (input) => Boolean(String(input || "").trim()) || "版本名称不能为空",
|
|
||||||
confirmButtonText: "保存",
|
|
||||||
});
|
|
||||||
historyActionKey.value = `${revision.id}:name`;
|
|
||||||
const { data } = await api.name(fileId, revision.id, {
|
|
||||||
change_summary: value.trim(),
|
|
||||||
});
|
|
||||||
revisions.value = revisions.value.map((item) => item.id === revision.id ? {
|
|
||||||
...item,
|
|
||||||
...data,
|
|
||||||
created_by_name: item.created_by_name,
|
|
||||||
created_by_avatar_url: item.created_by_avatar_url,
|
|
||||||
} : item);
|
|
||||||
ElMessage.success("版本名称已保存");
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error === "cancel" || error === "close") return;
|
|
||||||
ElMessage.error(await getApiErrorMessage(error, "版本命名失败"));
|
|
||||||
} finally {
|
|
||||||
historyActionKey.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const previewRevision = (revision: CollaborationRevision) => {
|
|
||||||
if (!historyFile.value || historyActionKey.value || restoringRevisionId.value) return;
|
|
||||||
const fileId = historyFile.value.id;
|
|
||||||
const previewRoute = router.resolve({
|
|
||||||
name: props.target.scope === "ledger" ? "LedgerRevisionPreview" : "OfficeCollaborationRevisionPreview",
|
|
||||||
params: { fileId, id: revision.id },
|
|
||||||
});
|
|
||||||
window.open(previewRoute.href, "_blank", "noopener,noreferrer");
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveRevisionAs = (revision: CollaborationRevision) => {
|
|
||||||
if (!canSaveCopy.value) return;
|
|
||||||
if (props.target.scope === "ledger") { void saveLedgerRevisionAs(revision); return; }
|
|
||||||
if (!historyFile.value || historyActionKey.value) return;
|
|
||||||
revisionSaveAsTarget.value = revision;
|
|
||||||
revisionSaveAsFolderId.value = historyFile.value.folder_id || null;
|
|
||||||
revisionSaveAsTitle.value = revisionCopyTitle(revision);
|
|
||||||
revisionSaveAsDialogVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitRevisionSaveAs = async (payload: { title: string; folderId: string | null }) => {
|
|
||||||
const revision = revisionSaveAsTarget.value;
|
|
||||||
const file = historyFile.value;
|
|
||||||
if (!revision || !file || revisionSaveAsSaving.value || props.target.scope !== "project") return;
|
|
||||||
revisionSaveAsSaving.value = true;
|
|
||||||
historyActionKey.value = `${revision.id}:copy`;
|
|
||||||
try {
|
|
||||||
const { data } = await copyCollaborationRevision(props.target.studyId, file.id, revision.id, {
|
|
||||||
title: payload.title,
|
|
||||||
folder_id: payload.folderId,
|
|
||||||
});
|
|
||||||
revisionSaveAsDialogVisible.value = false;
|
|
||||||
revisionSaveAsTarget.value = null;
|
|
||||||
emit("changed");
|
|
||||||
ElMessage.success(`已在工作区创建“${data.title}”`);
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(await getApiErrorMessage(error, "历史版本另存为失败"));
|
|
||||||
} finally {
|
|
||||||
revisionSaveAsSaving.value = false;
|
|
||||||
historyActionKey.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRevisionMoreAction = (command: string, revision: CollaborationRevision) => {
|
|
||||||
if (command === "restore") void restoreRevision(revision);
|
|
||||||
if (command === "delete") void deleteRevision(revision);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteRevision = async (revision: CollaborationRevision) => {
|
|
||||||
if (!historyFile.value || historyActionKey.value || revision.id === historyFile.value.current_revision_id || props.target.scope !== "project" || !historyFile.value.can_manage) return;
|
|
||||||
const fileId = historyFile.value.id;
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
"删除此历史版本后将不再显示且无法恢复,是否继续?",
|
|
||||||
"删除历史版本",
|
|
||||||
{ type: "warning", confirmButtonText: "删除", confirmButtonClass: "el-button--danger" },
|
|
||||||
);
|
|
||||||
historyActionKey.value = `${revision.id}:delete`;
|
|
||||||
await deleteCollaborationRevision(props.target.studyId, fileId, revision.id);
|
|
||||||
revisions.value = revisions.value.filter((item) => item.id !== revision.id);
|
|
||||||
ElMessage.success("历史版本已删除");
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error === "cancel" || error === "close") return;
|
|
||||||
ElMessage.error(await getApiErrorMessage(error, "历史版本删除失败"));
|
|
||||||
} finally {
|
|
||||||
historyActionKey.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const restoreRevision = async (revision: CollaborationRevision) => {
|
|
||||||
if (!historyFile.value || restoringRevisionId.value || !canRestore.value) return;
|
|
||||||
const file = historyFile.value;
|
|
||||||
const fileId = file.id;
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(props.target.scope === "ledger" ? "恢复将生成新版本,已打开的台账需重新加载。请确认协作者已保存内容后继续。" : "恢复此历史版本将生成一个新版本,是否继续?", "恢复历史版本", { type: "warning" });
|
|
||||||
restoringRevisionId.value = revision.id;
|
|
||||||
const { data: restored } = await api.restore(file, revision.id);
|
|
||||||
const restoredForDisplay: CollaborationRevision = {
|
|
||||||
...restored,
|
|
||||||
created_by_name: restored.created_by_name || auth.user?.full_name || auth.user?.email || null,
|
|
||||||
created_by_avatar_url: restored.created_by_avatar_url || auth.user?.avatar_url || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (historyFile.value?.id === fileId) {
|
|
||||||
historyFile.value = {
|
|
||||||
...historyFile.value,
|
|
||||||
current_revision_id: restoredForDisplay.id,
|
|
||||||
current_revision_no: restoredForDisplay.revision_no,
|
|
||||||
current_revision_file_size: restoredForDisplay.file_size,
|
|
||||||
current_revision_mime_type: restoredForDisplay.mime_type,
|
|
||||||
current_revision_created_at: restoredForDisplay.created_at,
|
|
||||||
};
|
|
||||||
revisions.value = [
|
|
||||||
restoredForDisplay,
|
|
||||||
...revisions.value.filter((item) => item.id !== restoredForDisplay.id),
|
|
||||||
].sort((left, right) => right.revision_no - left.revision_no);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [revisionResponse, fileResponse] = await Promise.all([
|
|
||||||
api.revisions(fileId),
|
|
||||||
api.file(fileId),
|
|
||||||
]);
|
|
||||||
if (historyFile.value?.id === fileId) {
|
|
||||||
revisions.value = revisionResponse.data;
|
|
||||||
const refreshedFile = fileResponse.data;
|
|
||||||
emit("updated", refreshedFile);
|
|
||||||
if (refreshedFile) historyFile.value = refreshedFile;
|
|
||||||
}
|
|
||||||
emit("changed");
|
|
||||||
ElMessage.success("已恢复并生成新版本");
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error === "cancel" || error === "close") return;
|
|
||||||
ElMessage.error(await getApiErrorMessage(error, "版本恢复失败"));
|
|
||||||
} finally {
|
|
||||||
restoringRevisionId.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const saveLedgerRevisionAs = async (revision: CollaborationRevision) => {
|
|
||||||
const file = historyFile.value;
|
|
||||||
if (!file || historyActionKey.value || restoringRevisionId.value || !canSaveCopy.value) return;
|
|
||||||
const suggestedName = revisionCopyTitle(revision);
|
|
||||||
const destinationPromise = prepareSaveFile(suggestedName);
|
|
||||||
historyActionKey.value = `${revision.id}:copy`;
|
|
||||||
try {
|
|
||||||
const destination = await destinationPromise;
|
|
||||||
if (!destination) return;
|
|
||||||
const { data } = await downloadLedgerRevision(file.id, revision.id);
|
|
||||||
await saveFileWithFeedback({ suggestedName, mimeType: revision.mime_type, data }, { title: "另存台账历史版本" }, destination);
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(await getApiErrorMessage(error, "历史版本另存为失败"));
|
|
||||||
} finally { historyActionKey.value = null; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const importNewRevision = async () => {
|
|
||||||
const file = historyFile.value;
|
|
||||||
if (!file || props.target.scope !== "ledger" || !canRestore.value || historyActionKey.value || restoringRevisionId.value) return;
|
|
||||||
historyActionKey.value = "import";
|
|
||||||
try {
|
|
||||||
const [upload] = await pickFiles({ accept: [".xlsx"], multiple: false, title: "选择台账 Excel 文件" });
|
|
||||||
if (!upload) return;
|
|
||||||
await ElMessageBox.confirm("导入将生成新版本,已打开的台账需重新加载。请确认协作者已保存内容后继续。", "导入新版本", { type: "warning" });
|
|
||||||
await importLedger(file.id, upload, file.generation);
|
|
||||||
await refreshHistory();
|
|
||||||
emit("changed");
|
|
||||||
ElMessage.success("已导入为新版本");
|
|
||||||
} catch (error) {
|
|
||||||
if (error !== "cancel" && error !== "close") ElMessage.error(await getApiErrorMessage(error, "台账导入失败"));
|
|
||||||
} finally { historyActionKey.value = null; }
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => { void openHistory(props.file); });
|
|
||||||
onDeactivated(closeHistory);
|
|
||||||
onBeforeUnmount(() => { clearHistoryRefreshTimers(); historyRequestSequence += 1; });
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.history-dialog__title { display: flex; align-items: flex-start; gap: 12px; min-width: 0; padding-right: 44px; }
|
|
||||||
.history-dialog__title > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 5px; }
|
|
||||||
.history-dialog__title strong { overflow: hidden; color: var(--ctms-text-main); font-size: 18px; font-weight: 700; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.history-dialog__meta { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; color: var(--ctms-text-secondary); font-size: 12px; line-height: 1.35; }
|
|
||||||
.history-dialog__meta span { display: inline-flex; align-items: center; white-space: nowrap; }
|
|
||||||
.history-dialog__meta span + span::before { width: 1px; height: 13px; margin: 0 13px; background: var(--ctms-border-color); content: ""; }
|
|
||||||
.history-file-badge { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex: 0 0 auto; border-radius: 6px; color: #fff; font-size: 13px; font-weight: 750; }
|
|
||||||
.history-file-badge.is-word { background: #4472c4; }
|
|
||||||
.history-file-badge.is-cell { background: #2e8b57; }
|
|
||||||
.history-file-badge.is-slide { background: #d05a35; }
|
|
||||||
.history-toolbar { display: flex; align-items: center; gap: 8px; min-height: 52px; padding: 7px 18px; border-bottom: 1px solid var(--ctms-border-color); }
|
|
||||||
.history-described-filter { display: inline-flex; align-items: center; gap: 8px; color: var(--ctms-text-main); font-size: 13px; }
|
|
||||||
.history-toolbar__spacer { flex: 1; }
|
|
||||||
.history-source-filter { width: 132px; }
|
|
||||||
.history-panel { display: flex; min-height: 0; margin: 14px 18px 18px; overflow: hidden; flex: 1; flex-direction: column; border: 1px solid var(--ctms-border-color); border-radius: 9px; background: var(--ctms-bg-card); }
|
|
||||||
.history-list-header,
|
|
||||||
.history-row { display: grid; grid-template-columns: 70px 130px 78px 96px minmax(140px, 1fr) 202px; min-width: 760px; align-items: center; column-gap: 8px; }
|
|
||||||
.history-list-header { min-height: 44px; padding: 0 18px; flex: 0 0 auto; border-bottom: 1px solid var(--ctms-border-color); background: var(--ctms-bg-muted); color: var(--ctms-text-regular); font-size: 13px; font-weight: 650; }
|
|
||||||
.history-groups { min-height: 0; max-height: none; overflow: auto; flex: 1; }
|
|
||||||
.history-day-group { min-width: 760px; padding: 0 18px; }
|
|
||||||
.history-day-group + .history-day-group { border-top: 1px solid var(--ctms-border-color); }
|
|
||||||
.history-day-group h3 { margin: 0; padding: 15px 0 5px; color: var(--ctms-text-main); font-size: 15px; font-weight: 700; line-height: 1.25; }
|
|
||||||
.history-row { min-height: 48px; padding: 0; border-bottom: 1px solid color-mix(in srgb, var(--ctms-border-color) 42%, transparent); color: var(--ctms-text-regular); font-size: 13px; transition: background-color 0.16s ease; }
|
|
||||||
.history-row:last-child { border-bottom: 0; }
|
|
||||||
.history-row:hover { background: var(--ctms-bg-muted); }
|
|
||||||
.history-row.is-current { background: color-mix(in srgb, var(--ctms-primary) 5%, var(--ctms-bg-card)); }
|
|
||||||
.history-time { padding-left: 5px; color: var(--ctms-text-main); font-variant-numeric: tabular-nums; }
|
|
||||||
.history-author { display: inline-flex; min-width: 0; align-items: center; gap: 7px; }
|
|
||||||
.history-author :deep(.el-avatar) { flex: 0 0 auto; background: var(--ctms-primary); color: #fff; font-size: 11px; font-weight: 700; }
|
|
||||||
.history-author > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.history-source { color: var(--ctms-text-regular); }
|
|
||||||
.history-description { display: flex; min-width: 0; align-items: center; gap: 8px; }
|
|
||||||
.history-description > span { overflow: hidden; color: var(--ctms-text-secondary); text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.history-row__action { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 1px; opacity: 0.72; transition: opacity 0.16s ease; }
|
|
||||||
.history-row__action :deep(.el-button) { margin-left: 0; padding: 4px 5px; font-size: 12px; }
|
|
||||||
.history-row__more { letter-spacing: 1px; }
|
|
||||||
.history-row:hover .history-row__action,
|
|
||||||
.history-row:focus-within .history-row__action { opacity: 1; }
|
|
||||||
:global(.history-dialog.el-dialog) { display: flex; width: 840px; height: min(760px, 82vh); max-width: calc(100vw - 40px); max-height: calc(100vh - 64px); margin: 8vh auto 0 !important; overflow: hidden; flex-direction: column; padding: 0; border-radius: 14px; }
|
|
||||||
:global(.history-dialog.el-dialog .el-dialog__header) { margin: 0; padding: 16px 18px 14px; flex: 0 0 auto; border-bottom: 1px solid var(--ctms-border-color); }
|
|
||||||
:global(.history-dialog.el-dialog .el-dialog__headerbtn) { top: 13px; right: 13px; width: 36px; height: 36px; }
|
|
||||||
:global(.history-dialog.el-dialog .el-dialog__body) { display: flex; min-height: 0; flex: 1; flex-direction: column; padding: 0; }
|
|
||||||
:global(.history-dialog-overlay.el-overlay),
|
|
||||||
:global(.history-dialog-overlay .el-overlay-dialog) { overflow: hidden; }
|
|
||||||
|
|
||||||
:global(body.is-desktop-runtime .history-dialog.el-dialog) { margin: 0 auto !important; --el-dialog-padding-primary: 0; padding: 0 !important; overflow: hidden !important; }
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.history-dialog__meta span + span::before { margin: 0 8px; }
|
|
||||||
.history-toolbar { flex-wrap: wrap; }
|
|
||||||
.history-toolbar__spacer { display: none; }
|
|
||||||
.history-source-filter { margin-left: auto; }
|
|
||||||
.history-panel { margin-right: 12px; margin-left: 12px; overflow-x: auto; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<template>
|
|
||||||
<CollaborationHistoryDialog :file="ledger" :target="{ scope: 'ledger' }"
|
|
||||||
@close="emit('close')" @changed="emit('changed')" @updated="emit('updated', $event)" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import CollaborationHistoryDialog from "../collaboration/CollaborationHistoryDialog.vue";
|
|
||||||
import type { Ledger } from "../../types/ledger";
|
|
||||||
import type { CollaborationHistoryFile } from "../../api/collaborationHistory";
|
|
||||||
|
|
||||||
const { ledger } = defineProps<{ ledger: Ledger }>();
|
|
||||||
const emit = defineEmits<{ close: []; changed: []; updated: [file: CollaborationHistoryFile] }>();
|
|
||||||
</script>
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-dialog :model-value="true" title="信息维护" width="560px" class="ledger-info-dialog"
|
|
||||||
append-to-body align-center :close-on-click-modal="false" @close="emit('close')">
|
|
||||||
<template #header="{ titleId }">
|
|
||||||
<div class="ledger-info-header">
|
|
||||||
<span class="ledger-info-header__icon" aria-hidden="true"><el-icon><EditPen /></el-icon></span>
|
|
||||||
<h2 :id="titleId">信息维护</h2>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-if="error" class="ledger-error" role="alert">{{ error }} <el-button link @click="refresh">重试</el-button></div>
|
|
||||||
<el-form id="ledger-info-form" v-loading="loading" class="ledger-info-form" label-position="top"
|
|
||||||
:disabled="busy || loading || !current.can_manage" @submit.prevent="save">
|
|
||||||
<el-form-item label="台账名称" required>
|
|
||||||
<el-input v-model="form.title" maxlength="240" aria-label="台账名称" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="用途说明">
|
|
||||||
<template #label>用途说明<span class="ledger-field-optional">选填</span></template>
|
|
||||||
<el-input v-model="form.description" type="textarea" maxlength="500" :rows="3"
|
|
||||||
show-word-limit resize="vertical" aria-label="用途说明" />
|
|
||||||
</el-form-item>
|
|
||||||
<div class="ledger-info-details">
|
|
||||||
<el-form-item label="所有者" class="ledger-info-owner">
|
|
||||||
<div class="ledger-owner-identity">
|
|
||||||
<el-avatar :size="32" class="ledger-owner-avatar" aria-hidden="true">{{ ownerInitial }}</el-avatar>
|
|
||||||
<span class="ledger-owner-name" :title="current.owner_name">{{ current.owner_name || '—' }}</span>
|
|
||||||
</div>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="台账状态" class="ledger-info-status">
|
|
||||||
<el-radio-group v-model="form.status" class="ledger-status-options" aria-label="台账状态">
|
|
||||||
<el-radio-button value="ACTIVE">使用中</el-radio-button>
|
|
||||||
<el-radio-button value="ARCHIVED">已归档(只读)</el-radio-button>
|
|
||||||
</el-radio-group>
|
|
||||||
</el-form-item>
|
|
||||||
</div>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button :disabled="busy" @click="emit('close')">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="busy" :disabled="loading || !current.can_manage"
|
|
||||||
native-type="submit" form="ledger-info-form">保存信息</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref } from "vue";
|
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
|
||||||
import { EditPen } from "@element-plus/icons-vue";
|
|
||||||
import { fetchLedger, updateLedger } from "../../api/ledgers";
|
|
||||||
import type { Ledger, LedgerSettings } from "../../types/ledger";
|
|
||||||
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
|
|
||||||
|
|
||||||
const props = defineProps<{ ledger: Ledger }>();
|
|
||||||
const emit = defineEmits<{ close: []; changed: [] }>();
|
|
||||||
const current = ref(props.ledger);
|
|
||||||
const ownerInitial = computed(() => current.value.owner_name?.trim().slice(0, 1).toUpperCase() || "?");
|
|
||||||
const toSettings = (ledger: Ledger): LedgerSettings => ({
|
|
||||||
title: ledger.title.replace(/\.xlsx$/i, ""), description: ledger.description, status: ledger.status,
|
|
||||||
owner_id: ledger.owner_id, allow_export: ledger.allow_export,
|
|
||||||
allow_sheet_structure_edit: ledger.allow_sheet_structure_edit, generation: ledger.generation,
|
|
||||||
});
|
|
||||||
const form = ref(toSettings(props.ledger));
|
|
||||||
const loading = ref(false);
|
|
||||||
const busy = ref(false);
|
|
||||||
const error = ref("");
|
|
||||||
const refresh = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
error.value = "";
|
|
||||||
try {
|
|
||||||
current.value = (await fetchLedger(current.value.id)).data;
|
|
||||||
form.value = toSettings(current.value);
|
|
||||||
if (!current.value.can_manage) {
|
|
||||||
emit("changed");
|
|
||||||
emit("close");
|
|
||||||
}
|
|
||||||
} catch (cause) { error.value = await getApiErrorMessage(cause, "台账信息加载失败"); }
|
|
||||||
finally { loading.value = false; }
|
|
||||||
};
|
|
||||||
const save = async () => {
|
|
||||||
if (busy.value || loading.value || !current.value.can_manage) return;
|
|
||||||
if (!form.value.title.trim()) { ElMessage.warning("请输入台账名称"); return; }
|
|
||||||
busy.value = true;
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm("此操作会使已打开的台账重新加载。请确认协作者已保存内容后继续。", "更新台账信息", {
|
|
||||||
confirmButtonText: "继续", cancelButtonText: "取消", type: "warning",
|
|
||||||
});
|
|
||||||
await updateLedger(current.value.id, form.value);
|
|
||||||
ElMessage.success("台账信息已保存");
|
|
||||||
emit("changed");
|
|
||||||
emit("close");
|
|
||||||
} catch (cause) {
|
|
||||||
if (cause !== "cancel" && cause !== "close") error.value = await getApiErrorMessage(cause, "信息保存失败,请刷新后重试");
|
|
||||||
} finally { busy.value = false; }
|
|
||||||
};
|
|
||||||
onMounted(refresh);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ledger-info-header { display: flex; align-items: center; gap: 10px; padding-right: 30px; }
|
|
||||||
.ledger-info-header__icon { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex-shrink: 0; border-radius: 9px; background: var(--ctms-primary-light); color: var(--ctms-primary); font-size: 17px; }
|
|
||||||
.ledger-info-header h2 { margin: 0; color: var(--ctms-text-main); font-size: 18px; font-weight: 700; line-height: 26px; }
|
|
||||||
.ledger-info-form :deep(.el-form-item) { margin-bottom: 18px; }
|
|
||||||
.ledger-info-form :deep(.el-form-item__label) { height: auto; margin-bottom: 7px; color: var(--ctms-text-main); font-size: 13px; font-weight: 600; line-height: 20px; }
|
|
||||||
.ledger-field-optional { margin-left: 7px; color: var(--ctms-text-secondary); font-size: 12px; font-weight: 400; }
|
|
||||||
.ledger-info-form :deep(.el-input__wrapper) { min-height: 36px; padding: 0 11px; border-radius: 8px; }
|
|
||||||
.ledger-info-form :deep(.el-input__inner) { height: 36px; color: var(--ctms-text-main); font-size: 14px; }
|
|
||||||
.ledger-info-form :deep(.el-textarea__inner) { min-height: 88px; padding: 9px 11px 23px; border-radius: 8px; background: var(--ctms-bg-card); color: var(--ctms-text-main); font-size: 13px; line-height: 20px; }
|
|
||||||
.ledger-info-form :deep(.el-input__count) { right: 11px; bottom: 5px; background: var(--ctms-bg-card); color: var(--ctms-text-disabled); font-size: 11px; line-height: 16px; }
|
|
||||||
.ledger-info-details { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: 20px; padding-top: 16px; border-top: 1px solid var(--ctms-border-color); }
|
|
||||||
.ledger-info-details :deep(.el-form-item) { min-width: 0; margin-bottom: 0; }
|
|
||||||
.ledger-owner-identity { display: flex; align-items: center; gap: 9px; min-width: 0; width: 100%; min-height: 36px; }
|
|
||||||
.ledger-owner-avatar { flex-shrink: 0; background: var(--ctms-bg-muted); color: var(--ctms-primary); font-size: 13px; font-weight: 700; }
|
|
||||||
.ledger-owner-name { overflow: hidden; color: var(--ctms-text-regular); font-size: 13px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.ledger-status-options { display: flex; flex-wrap: nowrap; width: 100%; }
|
|
||||||
.ledger-status-options :deep(.el-radio-button) { flex: 1; }
|
|
||||||
.ledger-status-options :deep(.el-radio-button__inner) { display: flex; align-items: center; justify-content: center; width: 100%; height: 36px; padding: 0 10px; font-size: 12px; line-height: 18px; }
|
|
||||||
.ledger-status-options :deep(.el-radio-button:first-child .el-radio-button__inner) { border-radius: 8px 0 0 8px; }
|
|
||||||
.ledger-status-options :deep(.el-radio-button:last-child .el-radio-button__inner) { border-radius: 0 8px 8px 0; }
|
|
||||||
.ledger-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; margin-bottom: 16px; color: var(--ctms-danger); background: color-mix(in srgb, var(--ctms-danger) 8%, var(--ctms-bg-card)); border-radius: 8px; font-size: 13px; }
|
|
||||||
:global(.ledger-info-dialog.el-dialog) { display: flex; flex-direction: column; max-width: calc(100vw - 32px); max-height: calc(100vh - 48px); max-height: calc(100dvh - 48px); padding: 0; overflow: hidden; border: 1px solid var(--ctms-border-color); border-radius: 14px; background: var(--ctms-bg-card); }
|
|
||||||
:global(.ledger-info-dialog.el-dialog .el-dialog__header) { flex-shrink: 0; margin: 0; padding: 16px 22px; border-bottom: 1px solid var(--ctms-border-color); background: var(--ctms-bg-card); }
|
|
||||||
:global(.ledger-info-dialog.el-dialog .el-dialog__headerbtn) { top: 16px; right: 16px; width: 32px; height: 32px; }
|
|
||||||
:global(.ledger-info-dialog.el-dialog .el-dialog__body) { min-height: 0; padding: 20px 22px; overflow-y: auto; }
|
|
||||||
:global(.ledger-info-dialog.el-dialog .el-dialog__footer) { display: flex; flex-shrink: 0; align-items: center; justify-content: flex-end; gap: 10px; padding: 13px 22px; border-top: 1px solid var(--ctms-border-color); background: var(--ctms-bg-muted); }
|
|
||||||
:global(.ledger-info-dialog.el-dialog .el-dialog__footer .el-button) { height: 34px; min-width: 80px; margin-left: 0; padding: 0 16px; border-radius: 8px; font-size: 13px; }
|
|
||||||
:global(body.is-desktop-runtime .ledger-info-dialog.el-dialog) { max-height: calc(100dvh - 96px); }
|
|
||||||
@media (max-width: 540px) {
|
|
||||||
.ledger-info-details { grid-template-columns: minmax(0, 1fr); gap: 16px; }
|
|
||||||
:global(.ledger-info-dialog.el-dialog .el-dialog__header),
|
|
||||||
:global(.ledger-info-dialog.el-dialog .el-dialog__body),
|
|
||||||
:global(.ledger-info-dialog.el-dialog .el-dialog__footer) { padding-left: 16px; padding-right: 16px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { flushPromises, mount } from "@vue/test-utils";
|
|
||||||
import LedgerPanel from "./LedgerPanel.vue";
|
|
||||||
import { fetchLedgers, initializeLedgers } from "../../api/ledgers";
|
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({ user: { is_admin: false }, push: vi.fn() }));
|
|
||||||
vi.mock("../../api/ledgers", () => ({ fetchLedgers: vi.fn(), initializeLedgers: vi.fn() }));
|
|
||||||
vi.mock("../../store/auth", () => ({ useAuthStore: () => state }));
|
|
||||||
vi.mock("vue-router", () => ({ useRouter: () => ({ push: state.push }) }));
|
|
||||||
vi.mock("./LedgerInfoDialog.vue", () => ({ default: { template: "<div />" } }));
|
|
||||||
vi.mock("./LedgerHistoryDialog.vue", () => ({ default: { template: "<div />" } }));
|
|
||||||
vi.mock("../collaboration/CollaborationAccessDialog.vue", () => ({ default: { template: "<div />" } }));
|
|
||||||
vi.mock("../../utils/apiErrorMessage", () => ({ getApiErrorMessage: async (_error: unknown, fallback: string) => fallback }));
|
|
||||||
|
|
||||||
const ledger = {
|
|
||||||
id: "ledger-a", title: "医学事务部合同台账明细表.xlsx", description: "合同台账", owner_name: "负责人",
|
|
||||||
status: "ACTIVE", can_edit: true, can_manage: false, current_revision_created_at: "2026-09-03T00:00:00Z",
|
|
||||||
};
|
|
||||||
const render = () => mount(LedgerPanel, { global: { stubs: {
|
|
||||||
ElButton: { template: '<button><slot /></button>' }, ElIcon: { template: '<span><slot /></span>' },
|
|
||||||
ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true,
|
|
||||||
} } });
|
|
||||||
|
|
||||||
describe("global ledger entries", () => {
|
|
||||||
beforeEach(() => { vi.clearAllMocks(); state.user.is_admin = false; });
|
|
||||||
it("opens the granted ledger without needing a selected project", async () => {
|
|
||||||
vi.mocked(fetchLedgers).mockResolvedValue({ data: [ledger] } as any);
|
|
||||||
const wrapper = render();
|
|
||||||
await flushPromises();
|
|
||||||
expect(wrapper.text()).toContain("医学事务部合同台账明细表");
|
|
||||||
expect(wrapper.text()).not.toContain("台账管理");
|
|
||||||
await wrapper.get(".ledger-open").trigger("click");
|
|
||||||
expect(state.push).toHaveBeenCalledWith({ name: "LedgerWorkspace", params: { fileId: "ledger-a" } });
|
|
||||||
wrapper.unmount();
|
|
||||||
});
|
|
||||||
it("offers initialization only to administrators and shows an authorization empty state", async () => {
|
|
||||||
vi.mocked(fetchLedgers).mockResolvedValue({ data: [] } as any);
|
|
||||||
const viewer = render();
|
|
||||||
await flushPromises();
|
|
||||||
expect(viewer.text()).toContain("暂未获得台账访问权限");
|
|
||||||
expect(viewer.text()).not.toContain("初始化两本台账");
|
|
||||||
viewer.unmount();
|
|
||||||
state.user.is_admin = true;
|
|
||||||
vi.mocked(initializeLedgers).mockResolvedValue({ data: [ledger] } as any);
|
|
||||||
const admin = render();
|
|
||||||
await flushPromises();
|
|
||||||
await admin.get("button").trigger("click");
|
|
||||||
await flushPromises();
|
|
||||||
expect(initializeLedgers).toHaveBeenCalledOnce();
|
|
||||||
expect(admin.findAll(".ledger-card")).toHaveLength(1);
|
|
||||||
admin.unmount();
|
|
||||||
});
|
|
||||||
it("shows a recoverable error instead of misreporting failed authorization loading as no ledgers", async () => {
|
|
||||||
vi.mocked(fetchLedgers).mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ data: [ledger] } as any);
|
|
||||||
const wrapper = render();
|
|
||||||
await flushPromises();
|
|
||||||
expect(wrapper.get('[role="alert"]').text()).toContain("台账加载失败");
|
|
||||||
await wrapper.get("button").trigger("click");
|
|
||||||
await flushPromises();
|
|
||||||
expect(wrapper.findAll(".ledger-card")).toHaveLength(1);
|
|
||||||
wrapper.unmount();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="ledger-panel" aria-labelledby="ledger-heading">
|
|
||||||
<header class="ledger-heading">
|
|
||||||
<span class="ledger-eyebrow">Collaborative Ledgers</span>
|
|
||||||
<div class="entry-title-row">
|
|
||||||
<h2 id="ledger-heading">协作台账</h2>
|
|
||||||
<EntryCountBadge v-if="ledgers.length">{{ ledgers.length }} 本可访问台账</EntryCountBadge>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div v-if="loading" class="ledger-state" role="status">正在加载台账…</div>
|
|
||||||
<div v-else-if="error" class="ledger-state" role="alert">
|
|
||||||
<p>{{ error }}</p><el-button @click="load">重新加载</el-button>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="!ledgers.length" class="ledger-state">
|
|
||||||
<p>{{ isAdmin ? '启用两本医学事务部台账,开始在线协作。' : '暂未获得台账访问权限,请联系管理员授权。' }}</p>
|
|
||||||
<el-button v-if="isAdmin" type="primary" :loading="initializing" @click="initialize">初始化两本台账</el-button>
|
|
||||||
</div>
|
|
||||||
<div v-else class="ledger-grid">
|
|
||||||
<article v-for="ledger in ledgers" :key="ledger.id" class="ledger-card">
|
|
||||||
<div class="ledger-card-top">
|
|
||||||
<div class="ledger-title-stack">
|
|
||||||
<h3 :title="displayTitle(ledger.title)">{{ displayTitle(ledger.title) }}</h3>
|
|
||||||
<p v-if="ledger.description?.trim()" class="ledger-description" :title="ledger.description">{{ ledger.description }}</p>
|
|
||||||
</div>
|
|
||||||
<span class="ledger-access" :class="{ 'is-readonly': !ledger.can_edit }">
|
|
||||||
{{ ledger.status === 'ARCHIVED' ? '已归档 · 只读' : ledger.can_edit ? '可编辑' : '只读' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<dl><div><dt>负责人</dt><dd>{{ ledger.owner_name }}</dd></div>
|
|
||||||
<div><dt>最近保存</dt><dd>{{ formatTime(ledger.current_revision_created_at) }}</dd></div></dl>
|
|
||||||
<footer>
|
|
||||||
<button class="ledger-open" type="button" @click="open(ledger)">打开台账 <el-icon><ArrowRight /></el-icon></button>
|
|
||||||
<el-dropdown v-if="ledger.can_manage" trigger="click" @command="handleManagementCommand($event, ledger)">
|
|
||||||
<el-button link>台账管理 <el-icon><ArrowDown /></el-icon></el-button>
|
|
||||||
<template #dropdown><el-dropdown-menu>
|
|
||||||
<el-dropdown-item command="info">信息维护</el-dropdown-item>
|
|
||||||
<el-dropdown-item command="access">访问与权限</el-dropdown-item>
|
|
||||||
<el-dropdown-item command="history">历史版本</el-dropdown-item>
|
|
||||||
</el-dropdown-menu></template>
|
|
||||||
</el-dropdown>
|
|
||||||
<el-button v-else link @click="openManagement('history', ledger)">历史版本</el-button>
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<LedgerInfoDialog v-if="management?.section === 'info'" :ledger="management.ledger" @close="management = null" @changed="load" />
|
|
||||||
<CollaborationAccessDialog v-if="management?.section === 'access'" :file="management.ledger" :target="{ scope: 'ledger' }"
|
|
||||||
@close="management = null" @changed="load" />
|
|
||||||
<LedgerHistoryDialog v-if="management?.section === 'history'" :ledger="management.ledger" @close="management = null" @changed="load" @updated="updateLedgerSummary" />
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref } from "vue";
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { ArrowDown, ArrowRight } from "@element-plus/icons-vue";
|
|
||||||
import { fetchLedgers, initializeLedgers } from "../../api/ledgers";
|
|
||||||
import { useAuthStore } from "../../store/auth";
|
|
||||||
import { isSystemAdmin } from "../../utils/roles";
|
|
||||||
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
|
|
||||||
import type { Ledger } from "../../types/ledger";
|
|
||||||
import type { CollaborationHistoryFile } from "../../api/collaborationHistory";
|
|
||||||
import LedgerInfoDialog from "./LedgerInfoDialog.vue";
|
|
||||||
import LedgerHistoryDialog from "./LedgerHistoryDialog.vue";
|
|
||||||
import CollaborationAccessDialog from "../collaboration/CollaborationAccessDialog.vue";
|
|
||||||
import EntryCountBadge from "../EntryCountBadge.vue";
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const auth = useAuthStore();
|
|
||||||
const isAdmin = computed(() => isSystemAdmin(auth.user));
|
|
||||||
const ledgers = ref<Ledger[]>([]);
|
|
||||||
const updateLedgerSummary = (file: CollaborationHistoryFile) => {
|
|
||||||
ledgers.value = ledgers.value.map((ledger) => ledger.id === file.id ? { ...ledger, ...file } as Ledger : ledger);
|
|
||||||
};
|
|
||||||
const loading = ref(true);
|
|
||||||
const initializing = ref(false);
|
|
||||||
const error = ref("");
|
|
||||||
type ManagementSection = "info" | "access" | "history";
|
|
||||||
const management = ref<{ section: ManagementSection; ledger: Ledger } | null>(null);
|
|
||||||
const openManagement = (section: ManagementSection, ledger: Ledger) => { management.value = { section, ledger }; };
|
|
||||||
const handleManagementCommand = (command: string, ledger: Ledger) => {
|
|
||||||
if (command === "info" || command === "access" || command === "history") openManagement(command, ledger);
|
|
||||||
};
|
|
||||||
const displayTitle = (title: string) => title.replace(/\.xlsx$/i, "");
|
|
||||||
const formatTime = (value: string | null) => value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "尚未保存";
|
|
||||||
const open = (ledger: Ledger) => router.push({ name: "LedgerWorkspace", params: { fileId: ledger.id } });
|
|
||||||
const load = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
error.value = "";
|
|
||||||
try { ledgers.value = (await fetchLedgers()).data; }
|
|
||||||
catch (cause) { ledgers.value = []; error.value = await getApiErrorMessage(cause, "台账加载失败"); }
|
|
||||||
finally { loading.value = false; }
|
|
||||||
};
|
|
||||||
const initialize = async () => {
|
|
||||||
if (initializing.value) return;
|
|
||||||
initializing.value = true;
|
|
||||||
try { ledgers.value = (await initializeLedgers()).data; }
|
|
||||||
catch (cause) { error.value = await getApiErrorMessage(cause, "台账初始化失败"); }
|
|
||||||
finally { initializing.value = false; }
|
|
||||||
};
|
|
||||||
onMounted(load);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ledger-panel { margin-top: 44px; padding-bottom: 28px; }
|
|
||||||
.ledger-heading { margin-bottom: 26px; }
|
|
||||||
.ledger-eyebrow { color: #387cf4; font-size: 11px; font-weight: 800; letter-spacing: 2px; text-transform: uppercase; }
|
|
||||||
.ledger-heading h2 { margin: 0; color: #162033; font-size: 24px; }
|
|
||||||
.ledger-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
|
|
||||||
.ledger-card { display: flex; flex-direction: column; min-width: 0; background: #fff; border: 1px solid rgba(226, 232, 240, 0.85); border-radius: 12px; box-shadow: 0 4px 15px rgba(15, 23, 42, 0.01), 0 1px 2px rgba(15, 23, 42, 0.02); }
|
|
||||||
.ledger-card-top { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; margin-bottom: 16px; }
|
|
||||||
.ledger-title-stack { display: flex; flex-direction: column; min-width: 0; }
|
|
||||||
.ledger-access { display: inline-flex; align-items: center; justify-content: center; height: 20px; padding: 0 8px; border-radius: 4px; color: #218266; background: #edfaf5; font-size: 10px; font-weight: 700; white-space: nowrap; }
|
|
||||||
.ledger-access.is-readonly { color: #667289; background: #f1f4f8; }
|
|
||||||
.ledger-card h3 { margin: 0; font-size: 14.5px; line-height: 18px; font-weight: 800; color: #0f172a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.ledger-description { color: #64748b; font-size: 10.5px; line-height: 13px; font-weight: 700; letter-spacing: 0.01em; margin: 1px 0 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.ledger-card dl { display: flex; flex-direction: column; gap: 5px; font-size: 11.5px; margin: 0 0 14px; }
|
|
||||||
.ledger-card dl > div { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
|
|
||||||
.ledger-card dt { color: #64748b; font-weight: 600; flex-shrink: 0; }
|
|
||||||
.ledger-card dd { margin: 0; color: #0f172a; font-weight: 550; text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.ledger-card footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid rgba(241, 245, 249, 0.85); margin-top: auto; padding-top: 10px; }
|
|
||||||
.ledger-open { display: flex; align-items: center; gap: 10px; border: 0; padding: 0; background: transparent; color: #1e3a8a; font: inherit; font-size: 11px; font-weight: 800; cursor: pointer; }
|
|
||||||
.ledger-open .el-icon { width: 20px; height: 20px; border-radius: 50%; background: rgba(59, 130, 246, 0.06); color: #2563eb; }
|
|
||||||
.ledger-card footer .el-button { height: 20px; padding: 0; font-size: 11px; }
|
|
||||||
.ledger-open:focus-visible { outline: 2px solid #387cf4; outline-offset: 4px; }
|
|
||||||
.ledger-state { padding: 28px; background: #fff; border: 1px dashed #d6e1ef; border-radius: 14px; color: #738199; font-size: 14px; }
|
|
||||||
.ledger-state p { margin: 0 0 14px; }
|
|
||||||
:global(.desktop-entry .ledger-card) { border-radius: 8px; }
|
|
||||||
</style>
|
|
||||||
Vendored
-1
@@ -3,7 +3,6 @@
|
|||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_BUILD_CHANNEL?: "dev" | "main" | "release" | "local";
|
readonly VITE_BUILD_CHANNEL?: "dev" | "main" | "release" | "local";
|
||||||
readonly VITE_BUILD_COMMIT?: string;
|
readonly VITE_BUILD_COMMIT?: string;
|
||||||
readonly VITE_DESKTOP_SERVER_URL?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ describe("admin project route permissions", () => {
|
|||||||
expect(source).toContain('name: "OfficeVersionPreview"');
|
expect(source).toContain('name: "OfficeVersionPreview"');
|
||||||
expect(source).toContain('path: "office-preview/collaboration/:fileId/revision/:id"');
|
expect(source).toContain('path: "office-preview/collaboration/:fileId/revision/:id"');
|
||||||
expect(source).toContain('name: "OfficeCollaborationRevisionPreview"');
|
expect(source).toContain('name: "OfficeCollaborationRevisionPreview"');
|
||||||
expect(source).toContain('path: "/ledgers/:fileId/revisions/:id/preview"');
|
expect(source.match(/component: OfficePreviewWorkspace/g)).toHaveLength(3);
|
||||||
expect(source).toContain('name: "LedgerRevisionPreview"');
|
|
||||||
expect(source).toContain('meta: { title: "台账历史版本预览", ledgerWorkspace: true }');
|
|
||||||
expect(source.match(/component: OfficePreviewWorkspace/g)).toHaveLength(4);
|
|
||||||
expect(source.match(/fullBleed: true/g)?.length).toBeGreaterThanOrEqual(2);
|
expect(source.match(/fullBleed: true/g)?.length).toBeGreaterThanOrEqual(2);
|
||||||
expect(source.match(/transientWorkspaceTask: true/g)?.length).toBeGreaterThanOrEqual(2);
|
expect(source.match(/transientWorkspaceTask: true/g)?.length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
@@ -142,7 +139,7 @@ describe("admin project route permissions", () => {
|
|||||||
expect(source).toContain("const canAccessProjectManagement = studyStore.currentStudyRole === \"PM\"");
|
expect(source).toContain("const canAccessProjectManagement = studyStore.currentStudyRole === \"PM\"");
|
||||||
expect(source).toContain("if (!canAccessProjectManagement)");
|
expect(source).toContain("if (!canAccessProjectManagement)");
|
||||||
expect(source).toContain('next({ path: DESKTOP_PROJECT_ENTRY_PATH });');
|
expect(source).toContain('next({ path: DESKTOP_PROJECT_ENTRY_PATH });');
|
||||||
expect(source).toContain("if (token && (isWorkbenchEntryPath(to.path) || to.meta.ledgerWorkspace) && studyStore.currentStudy)");
|
expect(source).toContain("if (token && isWorkbenchEntryPath(to.path) && studyStore.currentStudy)");
|
||||||
expect(source).not.toContain("if (token && !studyStore.currentStudy && !isDesktopRuntime)");
|
expect(source).not.toContain("if (token && !studyStore.currentStudy && !isDesktopRuntime)");
|
||||||
expect(source).not.toContain("ensureDefaultActiveStudy();");
|
expect(source).not.toContain("ensureDefaultActiveStudy();");
|
||||||
expect(source).not.toContain("ensureDefaultStudy();");
|
expect(source).not.toContain("ensureDefaultStudy();");
|
||||||
|
|||||||
@@ -121,18 +121,6 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: WebWorkbenchEntry,
|
component: WebWorkbenchEntry,
|
||||||
meta: { title: "工作台入口" },
|
meta: { title: "工作台入口" },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/ledgers/:fileId",
|
|
||||||
name: "LedgerWorkspace",
|
|
||||||
component: CollaborationWorkspace,
|
|
||||||
meta: { title: "协作台账", ledgerWorkspace: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/ledgers/:fileId/revisions/:id/preview",
|
|
||||||
name: "LedgerRevisionPreview",
|
|
||||||
component: OfficePreviewWorkspace,
|
|
||||||
meta: { title: "台账历史版本预览", ledgerWorkspace: true },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: DESKTOP_PROJECT_ENTRY_PATH,
|
path: DESKTOP_PROJECT_ENTRY_PATH,
|
||||||
name: "DesktopProjectEntry",
|
name: "DesktopProjectEntry",
|
||||||
@@ -653,7 +641,7 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
studyStore.rememberCurrentStudyForUser(auth.user.email);
|
studyStore.rememberCurrentStudyForUser(auth.user.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token && (isWorkbenchEntryPath(to.path) || to.meta.ledgerWorkspace) && studyStore.currentStudy) {
|
if (token && isWorkbenchEntryPath(to.path) && studyStore.currentStudy) {
|
||||||
studyStore.clearCurrentStudy();
|
studyStore.clearCurrentStudy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
DESKTOP_SERVER_URL_CHANGED_EVENT,
|
DESKTOP_SERVER_URL_CHANGED_EVENT,
|
||||||
DESKTOP_SERVER_URL_KEY,
|
DESKTOP_SERVER_URL_KEY,
|
||||||
getDefaultDesktopServerUrl,
|
|
||||||
getDesktopServerUrl,
|
getDesktopServerUrl,
|
||||||
normalizeDesktopServerUrl,
|
normalizeDesktopServerUrl,
|
||||||
setDesktopServerUrl,
|
setDesktopServerUrl,
|
||||||
@@ -32,7 +31,6 @@ const createMemoryStorage = (): Storage => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "");
|
|
||||||
Object.defineProperty(window, "localStorage", {
|
Object.defineProperty(window, "localStorage", {
|
||||||
value: createMemoryStorage(),
|
value: createMemoryStorage(),
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -42,7 +40,6 @@ beforeEach(() => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
setTauriRuntime(false);
|
setTauriRuntime(false);
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("desktop server config", () => {
|
describe("desktop server config", () => {
|
||||||
@@ -73,23 +70,6 @@ describe("desktop server config", () => {
|
|||||||
expect(listener).toHaveBeenCalledOnce();
|
expect(listener).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the build-time default while allowing a persisted manual override", () => {
|
|
||||||
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com");
|
|
||||||
|
|
||||||
expect(getDefaultDesktopServerUrl()).toBe("https://default.ctms.example.com/");
|
|
||||||
expect(getDesktopServerUrl()).toBe("https://default.ctms.example.com/");
|
|
||||||
|
|
||||||
setDesktopServerUrl("https://manual.ctms.example.com");
|
|
||||||
expect(getDesktopServerUrl()).toBe("https://manual.ctms.example.com/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores an invalid build-time default", () => {
|
|
||||||
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "http://public.ctms.example.com");
|
|
||||||
|
|
||||||
expect(getDefaultDesktopServerUrl()).toBeNull();
|
|
||||||
expect(getDesktopServerUrl()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requires a server URL only inside the Tauri runtime", () => {
|
it("requires a server URL only inside the Tauri runtime", () => {
|
||||||
expect(shouldRequireDesktopServerUrl()).toBe(false);
|
expect(shouldRequireDesktopServerUrl()).toBe(false);
|
||||||
setTauriRuntime(true);
|
setTauriRuntime(true);
|
||||||
@@ -97,11 +77,4 @@ describe("desktop server config", () => {
|
|||||||
setDesktopServerUrl("https://ctms.example.com");
|
setDesktopServerUrl("https://ctms.example.com");
|
||||||
expect(shouldRequireDesktopServerUrl()).toBe(false);
|
expect(shouldRequireDesktopServerUrl()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not require first-run configuration when a valid build-time default exists", () => {
|
|
||||||
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com");
|
|
||||||
setTauriRuntime(true);
|
|
||||||
|
|
||||||
expect(shouldRequireDesktopServerUrl()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,22 +51,13 @@ export const normalizeDesktopServerUrl = (value: string): DesktopServerUrlValida
|
|||||||
return { ok: true, url: `${parsed.origin}/` };
|
return { ok: true, url: `${parsed.origin}/` };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDefaultDesktopServerUrl = (): string | null => {
|
export const getDesktopServerUrl = (): string | null => {
|
||||||
const configured = import.meta.env.VITE_DESKTOP_SERVER_URL?.trim();
|
|
||||||
if (!configured) return null;
|
|
||||||
const result = normalizeDesktopServerUrl(configured);
|
|
||||||
return result.ok ? result.url : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStoredDesktopServerUrl = (): string | null => {
|
|
||||||
const stored = getStorage()?.getItem(DESKTOP_SERVER_URL_KEY);
|
const stored = getStorage()?.getItem(DESKTOP_SERVER_URL_KEY);
|
||||||
if (!stored) return null;
|
if (!stored) return null;
|
||||||
const result = normalizeDesktopServerUrl(stored);
|
const result = normalizeDesktopServerUrl(stored);
|
||||||
return result.ok ? result.url : null;
|
return result.ok ? result.url : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDesktopServerUrl = (): string | null => getStoredDesktopServerUrl() || getDefaultDesktopServerUrl();
|
|
||||||
|
|
||||||
export const hasDesktopServerUrl = (): boolean => Boolean(getDesktopServerUrl());
|
export const hasDesktopServerUrl = (): boolean => Boolean(getDesktopServerUrl());
|
||||||
|
|
||||||
export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
|
export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
|
||||||
@@ -83,7 +74,7 @@ export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationRe
|
|||||||
export const clearDesktopServerUrl = (): void => {
|
export const clearDesktopServerUrl = (): void => {
|
||||||
const previous = getDesktopServerUrl();
|
const previous = getDesktopServerUrl();
|
||||||
getStorage()?.removeItem(DESKTOP_SERVER_URL_KEY);
|
getStorage()?.removeItem(DESKTOP_SERVER_URL_KEY);
|
||||||
emitServerUrlChanged(previous, getDesktopServerUrl());
|
emitServerUrlChanged(previous, null);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl();
|
export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl();
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export { clientRuntime, type ClientRuntime, type RuntimeCapabilities } from "./c
|
|||||||
export {
|
export {
|
||||||
clearDesktopServerUrl,
|
clearDesktopServerUrl,
|
||||||
DESKTOP_SERVER_URL_CHANGED_EVENT,
|
DESKTOP_SERVER_URL_CHANGED_EVENT,
|
||||||
getDefaultDesktopServerUrl,
|
|
||||||
getDesktopServerUrl,
|
getDesktopServerUrl,
|
||||||
hasDesktopServerUrl,
|
hasDesktopServerUrl,
|
||||||
normalizeDesktopServerUrl,
|
normalizeDesktopServerUrl,
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const createStorage = (): Storage => {
|
|||||||
|
|
||||||
describe("secure session storage", () => {
|
describe("secure session storage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "");
|
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2026-07-02T00:00:00.000Z"));
|
vi.setSystemTime(new Date("2026-07-02T00:00:00.000Z"));
|
||||||
resetSecureSessionStorageForTests();
|
resetSecureSessionStorageForTests();
|
||||||
@@ -54,7 +53,6 @@ describe("secure session storage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllEnvs();
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
resetSecureSessionStorageForTests();
|
resetSecureSessionStorageForTests();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@@ -160,25 +158,6 @@ describe("secure session storage", () => {
|
|||||||
expect(invokeMock).not.toHaveBeenCalled();
|
expect(invokeMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores credentials for the build default when no desktop server override is stored", async () => {
|
|
||||||
vi.stubEnv("VITE_DESKTOP_SERVER_URL", "https://default.ctms.example.com");
|
|
||||||
localStorage.removeItem(DESKTOP_SERVER_URL_KEY);
|
|
||||||
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
|
|
||||||
invokeMock.mockResolvedValue(JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
token,
|
|
||||||
storedAt: Date.now(),
|
|
||||||
expiresAt: Date.now() + DESKTOP_SESSION_MAX_AGE_MS,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await initializeSecureSessionStorage();
|
|
||||||
|
|
||||||
expect(getSessionToken()).toBe(token);
|
|
||||||
expect(invokeMock).toHaveBeenCalledExactlyOnceWith("credential_get", {
|
|
||||||
serverOrigin: "https://default.ctms.example.com/",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears the previous server credential after a desktop server switch", async () => {
|
it("clears the previous server credential after a desktop server switch", async () => {
|
||||||
const previousServerOrigin = "https://old.ctms.example.com/";
|
const previousServerOrigin = "https://old.ctms.example.com/";
|
||||||
const nextServerOrigin = "https://new.ctms.example.com/";
|
const nextServerOrigin = "https://new.ctms.example.com/";
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
/* 工作台的项目和台账共用尺寸,说明文字是否存在不影响卡片高度。 */
|
|
||||||
:is(.web-entry, .desktop-entry) {
|
|
||||||
--entry-card-height: 172px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:is(.web-entry, .desktop-entry) .entry-title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px 12px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 项目显示第三行资料时,所有卡片一起增加一行的空间。 */
|
|
||||||
:is(.web-entry, .desktop-entry):has(.card-meta-list > .meta-item:nth-child(3)) {
|
|
||||||
--entry-card-height: 193px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:is(.web-entry, .desktop-entry) :is(.modern-project-card, .ledger-card, .skeleton-project-card) {
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: var(--entry-card-height);
|
|
||||||
min-height: var(--entry-card-height);
|
|
||||||
padding: 20px;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
:is(.web-entry, .desktop-entry) :is(.card-top-info, .ledger-card-top) {
|
|
||||||
min-height: 32px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:is(.web-entry, .desktop-entry) .project-title-stack small {
|
|
||||||
overflow: hidden;
|
|
||||||
line-height: 13px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
:is(.web-entry, .desktop-entry) .project-title-stack strong {
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:is(.web-entry, .desktop-entry) :is(.card-meta-list, .ledger-card dl) {
|
|
||||||
line-height: 16px;
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
export type LedgerRole = "VIEWER" | "EDITOR" | "MANAGER";
|
|
||||||
|
|
||||||
export interface Ledger {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
status: "ACTIVE" | "ARCHIVED";
|
|
||||||
owner_id: string;
|
|
||||||
owner_name: string;
|
|
||||||
generation: number;
|
|
||||||
updated_at: string;
|
|
||||||
current_revision_id: string | null;
|
|
||||||
current_revision_no: number | null;
|
|
||||||
current_revision_file_size: number | null;
|
|
||||||
current_revision_mime_type: string | null;
|
|
||||||
current_revision_created_at: string | null;
|
|
||||||
role: LedgerRole;
|
|
||||||
can_edit: boolean;
|
|
||||||
can_manage: boolean;
|
|
||||||
can_export: boolean;
|
|
||||||
allow_export: boolean;
|
|
||||||
allow_edit_request: boolean;
|
|
||||||
allow_sheet_structure_edit: boolean;
|
|
||||||
file_type: "cell";
|
|
||||||
extension: "xlsx";
|
|
||||||
can_request_edit: boolean;
|
|
||||||
edit_request_status?: "PENDING" | "APPROVED" | "REJECTED" | null;
|
|
||||||
can_transfer_ownership: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LedgerSettings = Pick<Ledger,
|
|
||||||
"title" | "description" | "status" | "owner_id" | "allow_export" | "allow_sheet_structure_edit" | "generation"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface LedgerCandidate {
|
|
||||||
user_id: string;
|
|
||||||
full_name: string;
|
|
||||||
email: string;
|
|
||||||
role_in_study: string;
|
|
||||||
can_be_editor: boolean;
|
|
||||||
can_be_manager: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LedgerMember extends Pick<LedgerCandidate, "user_id" | "full_name" | "email"> {
|
|
||||||
role: LedgerRole;
|
|
||||||
is_active: boolean;
|
|
||||||
protected_label?: string | null;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
export type OnlyOfficeResourceType = "attachment" | "version" | "collaboration_revision" | "ledger_revision";
|
export type OnlyOfficeResourceType = "attachment" | "version" | "collaboration_revision";
|
||||||
|
|
||||||
export interface OnlyOfficePreviewConfig {
|
export interface OnlyOfficePreviewConfig {
|
||||||
resource_type: OnlyOfficeResourceType;
|
resource_type: OnlyOfficeResourceType;
|
||||||
|
|||||||
@@ -53,10 +53,14 @@
|
|||||||
<div class="project-header">
|
<div class="project-header">
|
||||||
<div class="header-title-block">
|
<div class="header-title-block">
|
||||||
<span class="section-eyebrow">Project Workspace</span>
|
<span class="section-eyebrow">Project Workspace</span>
|
||||||
<div class="entry-title-row">
|
<h2>选择目标项目</h2>
|
||||||
<h2>选择目标项目</h2>
|
</div>
|
||||||
<EntryCountBadge v-if="!loading && projects.length">{{ projects.length }} 个可进入项目</EntryCountBadge>
|
|
||||||
</div>
|
<div class="header-actions-block">
|
||||||
|
<span v-if="!loading && projects.length" class="project-pill-badge">
|
||||||
|
<span class="badge-dot"></span>
|
||||||
|
<span>{{ projects.length }} 个可进入项目</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -126,7 +130,6 @@
|
|||||||
<h3>未检测到可用项目</h3>
|
<h3>未检测到可用项目</h3>
|
||||||
<p>您的账号尚未关联至任何研究项目,请联系系统管理员进行项目授权与分配。</p>
|
<p>您的账号尚未关联至任何研究项目,请联系系统管理员进行项目授权与分配。</p>
|
||||||
</div>
|
</div>
|
||||||
<LedgerPanel />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- 科技感转场遮罩 -->
|
<!-- 科技感转场遮罩 -->
|
||||||
@@ -154,9 +157,6 @@ import type { Study } from "../types/api";
|
|||||||
import { findFirstAccessibleProjectPath } from "../utils/projectRoutePermissions";
|
import { findFirstAccessibleProjectPath } from "../utils/projectRoutePermissions";
|
||||||
import { isSystemAdmin } from "../utils/roles";
|
import { isSystemAdmin } from "../utils/roles";
|
||||||
import AccountConnectionStatus from "../components/AccountConnectionStatus.vue";
|
import AccountConnectionStatus from "../components/AccountConnectionStatus.vue";
|
||||||
import LedgerPanel from "../components/ledgers/LedgerPanel.vue";
|
|
||||||
import EntryCountBadge from "../components/EntryCountBadge.vue";
|
|
||||||
import "../styles/entry-cards.css";
|
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const studyStore = useStudyStore();
|
const studyStore = useStudyStore();
|
||||||
@@ -508,7 +508,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-title-block h2 {
|
.header-title-block h2 {
|
||||||
margin: 0;
|
margin: 2px 0 0;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -543,6 +543,33 @@ onMounted(() => {
|
|||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-pill-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-refresh-work {
|
.btn-refresh-work {
|
||||||
min-width: 76px;
|
min-width: 76px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
@@ -565,6 +592,8 @@ onMounted(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 168px;
|
||||||
|
padding: 20px;
|
||||||
border: 1px solid rgba(226, 232, 240, 0.85);
|
border: 1px solid rgba(226, 232, 240, 0.85);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
@@ -769,6 +798,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
/* 骨架屏 */
|
/* 骨架屏 */
|
||||||
.skeleton-project-card {
|
.skeleton-project-card {
|
||||||
|
height: 168px;
|
||||||
|
padding: 20px;
|
||||||
border: 1px solid rgba(226, 232, 240, 0.85);
|
border: 1px solid rgba(226, 232, 240, 0.85);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
@@ -899,6 +930,11 @@ onMounted(() => {
|
|||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global([data-ctms-theme="dark"] .project-pill-badge) {
|
||||||
|
background: rgba(59, 130, 246, 0.12);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
:global([data-ctms-theme="dark"] .modern-project-card) {
|
:global([data-ctms-theme="dark"] .modern-project-card) {
|
||||||
background: rgba(15, 23, 42, 0.55);
|
background: rgba(15, 23, 42, 0.55);
|
||||||
border-color: rgba(51, 65, 85, 0.5);
|
border-color: rgba(51, 65, 85, 0.5);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="office-preview-page" :class="{ 'is-ledger-preview': resourceType === 'ledger_revision' }">
|
<section class="office-preview-page">
|
||||||
<header class="office-preview-page__header">
|
<header class="office-preview-page__header">
|
||||||
<button
|
<button
|
||||||
class="office-preview-page__back"
|
class="office-preview-page__back"
|
||||||
@@ -44,10 +44,9 @@ import {
|
|||||||
fetchCollaborationRevisionOnlyOfficeConfig,
|
fetchCollaborationRevisionOnlyOfficeConfig,
|
||||||
fetchVersionOnlyOfficeConfig,
|
fetchVersionOnlyOfficeConfig,
|
||||||
} from "../api/onlyoffice";
|
} from "../api/onlyoffice";
|
||||||
import { fetchLedgerRevisionPreview } from "../api/ledgers";
|
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import type { OnlyOfficePreviewConfig, OnlyOfficeResourceType } from "../types/onlyoffice";
|
import type { OnlyOfficePreviewConfig, OnlyOfficeResourceType } from "../types/onlyoffice";
|
||||||
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH, isTauriRuntime } from "../runtime";
|
import { DESKTOP_SERVER_URL_CHANGED_EVENT, ONLYOFFICE_HOST_PATH } from "../runtime";
|
||||||
import { getApiErrorMessage } from "../utils/apiErrorMessage";
|
import { getApiErrorMessage } from "../utils/apiErrorMessage";
|
||||||
import { workspaceTaskControllerKey } from "../components/layout/workspaceTaskController";
|
import { workspaceTaskControllerKey } from "../components/layout/workspaceTaskController";
|
||||||
|
|
||||||
@@ -68,7 +67,6 @@ let mounted = false;
|
|||||||
|
|
||||||
const resourceType = computed<OnlyOfficeResourceType>(() => {
|
const resourceType = computed<OnlyOfficeResourceType>(() => {
|
||||||
if (route.name === "OfficeAttachmentPreview") return "attachment";
|
if (route.name === "OfficeAttachmentPreview") return "attachment";
|
||||||
if (route.name === "LedgerRevisionPreview") return "ledger_revision";
|
|
||||||
if (route.name === "OfficeCollaborationRevisionPreview") return "collaboration_revision";
|
if (route.name === "OfficeCollaborationRevisionPreview") return "collaboration_revision";
|
||||||
return "version";
|
return "version";
|
||||||
});
|
});
|
||||||
@@ -113,8 +111,6 @@ const loadConfig = async () => {
|
|||||||
try {
|
try {
|
||||||
const response = resourceType.value === "attachment"
|
const response = resourceType.value === "attachment"
|
||||||
? await fetchAttachmentOnlyOfficeConfig(resourceId.value)
|
? await fetchAttachmentOnlyOfficeConfig(resourceId.value)
|
||||||
: resourceType.value === "ledger_revision"
|
|
||||||
? await fetchLedgerRevisionPreview(collaborationFileId.value, resourceId.value)
|
|
||||||
: resourceType.value === "collaboration_revision"
|
: resourceType.value === "collaboration_revision"
|
||||||
? await fetchCollaborationRevisionOnlyOfficeConfig(
|
? await fetchCollaborationRevisionOnlyOfficeConfig(
|
||||||
study.currentStudy?.id || "",
|
study.currentStudy?.id || "",
|
||||||
@@ -128,7 +124,7 @@ const loadConfig = async () => {
|
|||||||
}
|
}
|
||||||
previewConfig.value = response.data;
|
previewConfig.value = response.data;
|
||||||
viewerSequence.value += 1;
|
viewerSequence.value += 1;
|
||||||
if (resourceType.value !== "ledger_revision") study.setViewContext({ pageTitle: response.data.file_name, objectType: "Office 只读预览" });
|
study.setViewContext({ pageTitle: response.data.file_name, objectType: "Office 只读预览" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (sequence !== requestSequence.value) return;
|
if (sequence !== requestSequence.value) return;
|
||||||
errorMessage.value = await errorForResponse(error);
|
errorMessage.value = await errorForResponse(error);
|
||||||
@@ -171,10 +167,6 @@ const handleServerChange = () => {
|
|||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
if (workspaceTaskController?.closeTransientTask(route.path)) return;
|
if (workspaceTaskController?.closeTransientTask(route.path)) return;
|
||||||
if (resourceType.value === "ledger_revision") {
|
|
||||||
void router.push(isTauriRuntime() ? "/desktop/project-entry" : "/workbench");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.back();
|
router.back();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -197,7 +189,7 @@ onDeactivated(() => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
destroyViewer();
|
destroyViewer();
|
||||||
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
|
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
|
||||||
if (resourceType.value !== "ledger_revision") study.setViewContext(null);
|
study.setViewContext(null);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -214,8 +206,6 @@ onBeforeUnmount(() => {
|
|||||||
background: #f3f5f8;
|
background: #f3f5f8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.office-preview-page.is-ledger-preview { height: 100vh; height: 100dvh; }
|
|
||||||
|
|
||||||
.office-preview-page__header {
|
.office-preview-page__header {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -53,10 +53,14 @@
|
|||||||
<div class="project-header">
|
<div class="project-header">
|
||||||
<div class="header-title-block">
|
<div class="header-title-block">
|
||||||
<span class="section-eyebrow">Project Workspace</span>
|
<span class="section-eyebrow">Project Workspace</span>
|
||||||
<div class="entry-title-row">
|
<h2>选择目标项目</h2>
|
||||||
<h2>选择目标项目</h2>
|
</div>
|
||||||
<EntryCountBadge v-if="!loading && projects.length">{{ projects.length }} 个可进入项目</EntryCountBadge>
|
|
||||||
</div>
|
<div class="header-actions-block">
|
||||||
|
<span v-if="!loading && projects.length" class="project-pill-badge">
|
||||||
|
<span class="badge-dot"></span>
|
||||||
|
<span>{{ projects.length }} 个可进入项目</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,7 +155,6 @@
|
|||||||
<h3>未检测到可用项目</h3>
|
<h3>未检测到可用项目</h3>
|
||||||
<p>您的账号尚未关联至任何研究项目,请联系系统管理员进行项目授权与分配。</p>
|
<p>您的账号尚未关联至任何研究项目,请联系系统管理员进行项目授权与分配。</p>
|
||||||
</div>
|
</div>
|
||||||
<LedgerPanel />
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- 科技感转场遮罩 -->
|
<!-- 科技感转场遮罩 -->
|
||||||
@@ -188,9 +191,6 @@ import type { Study } from "../types/api";
|
|||||||
import { findFirstAccessibleProjectPath } from "../utils/projectRoutePermissions";
|
import { findFirstAccessibleProjectPath } from "../utils/projectRoutePermissions";
|
||||||
import { isSystemAdmin } from "../utils/roles";
|
import { isSystemAdmin } from "../utils/roles";
|
||||||
import AccountConnectionStatus from "../components/AccountConnectionStatus.vue";
|
import AccountConnectionStatus from "../components/AccountConnectionStatus.vue";
|
||||||
import LedgerPanel from "../components/ledgers/LedgerPanel.vue";
|
|
||||||
import EntryCountBadge from "../components/EntryCountBadge.vue";
|
|
||||||
import "../styles/entry-cards.css";
|
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const studyStore = useStudyStore();
|
const studyStore = useStudyStore();
|
||||||
@@ -327,7 +327,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 24px 28px 40px;
|
padding: 40px 28px;
|
||||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||||
border-right: 1px solid #e2e8f0;
|
border-right: 1px solid #e2e8f0;
|
||||||
box-shadow: 6px 0 30px rgba(15, 23, 42, 0.02);
|
box-shadow: 6px 0 30px rgba(15, 23, 42, 0.02);
|
||||||
@@ -537,7 +537,7 @@ onMounted(() => {
|
|||||||
z-index: 5;
|
z-index: 5;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 24px 48px 48px 40px;
|
padding: 48px 48px 48px 40px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,7 +550,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-title-block h2 {
|
.header-title-block h2 {
|
||||||
margin: 0;
|
margin: 2px 0 0;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -577,6 +577,33 @@ onMounted(() => {
|
|||||||
content: "";
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-pill-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(59, 130, 246, 0.08);
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
/* 网页端特有步骤条 - 已根据用户要求隐藏,保留 HTML 结构供测试匹配断言 */
|
/* 网页端特有步骤条 - 已根据用户要求隐藏,保留 HTML 结构供测试匹配断言 */
|
||||||
.workflow-strip {
|
.workflow-strip {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -636,6 +663,8 @@ onMounted(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 168px;
|
||||||
|
padding: 20px;
|
||||||
border: 1px solid rgba(226, 232, 240, 0.85);
|
border: 1px solid rgba(226, 232, 240, 0.85);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
@@ -841,6 +870,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
/* 骨架屏 */
|
/* 骨架屏 */
|
||||||
.skeleton-project-card {
|
.skeleton-project-card {
|
||||||
|
height: 168px;
|
||||||
|
padding: 20px;
|
||||||
border: 1px solid rgba(226, 232, 240, 0.85);
|
border: 1px solid rgba(226, 232, 240, 0.85);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
@@ -967,6 +998,11 @@ onMounted(() => {
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global([data-ctms-theme="dark"] .project-pill-badge) {
|
||||||
|
background: rgba(59, 130, 246, 0.12);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
:global([data-ctms-theme="dark"] .workflow-item) {
|
:global([data-ctms-theme="dark"] .workflow-item) {
|
||||||
background: rgba(15, 23, 42, 0.45);
|
background: rgba(15, 23, 42, 0.45);
|
||||||
border-color: rgba(51, 65, 85, 0.4);
|
border-color: rgba(51, 65, 85, 0.4);
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
const source = ["./CollaborationLibrary.vue", "../../components/collaboration/CollaborationAccessDialog.vue", "../../components/collaboration/CollaborationHistoryDialog.vue"]
|
const source = readFileSync(resolve(__dirname, "./CollaborationLibrary.vue"), "utf8");
|
||||||
.map((file) => readFileSync(resolve(__dirname, file), "utf8")).join("\n");
|
|
||||||
const accessApiSource = readFileSync(resolve(__dirname, "../../api/collaborationAccess.ts"), "utf8");
|
|
||||||
const historyApiSource = readFileSync(resolve(__dirname, "../../api/collaborationHistory.ts"), "utf8");
|
|
||||||
const librarySource = readFileSync(resolve(__dirname, "./CollaborationLibrary.vue"), "utf8");
|
|
||||||
|
|
||||||
describe("CollaborationLibrary UI contract", () => {
|
describe("CollaborationLibrary UI contract", () => {
|
||||||
it("opens the create dialog directly from the primary sidebar action", () => {
|
it("opens the create dialog directly from the primary sidebar action", () => {
|
||||||
@@ -77,17 +73,16 @@ describe("CollaborationLibrary UI contract", () => {
|
|||||||
expect(source).not.toContain("R${");
|
expect(source).not.toContain("R${");
|
||||||
expect(source).toContain("downloadCollaborationFile");
|
expect(source).toContain("downloadCollaborationFile");
|
||||||
expect(source).toContain("downloadFileWithFeedback(");
|
expect(source).toContain("downloadFileWithFeedback(");
|
||||||
expect(librarySource).not.toContain("prepareSaveFile");
|
expect(source).not.toContain("prepareSaveFile");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes history immediately after restoring a revision", () => {
|
it("refreshes history immediately after restoring a revision", () => {
|
||||||
expect(source).toContain("const restoringRevisionId = ref<string | null>(null)");
|
expect(source).toContain("const restoringRevisionId = ref<string | null>(null)");
|
||||||
expect(source).toContain(':loading="restoringRevisionId === row.id"');
|
expect(source).toContain(':loading="restoringRevisionId === row.id"');
|
||||||
expect(source).toContain("const { data: restored } = await api.restore");
|
expect(source).toContain("const { data: restored } = await restoreCollaborationRevision");
|
||||||
expect(historyApiSource).toContain("restoreCollaborationRevision(target.studyId");
|
|
||||||
expect(source).toContain("current_revision_id: restoredForDisplay.id");
|
expect(source).toContain("current_revision_id: restoredForDisplay.id");
|
||||||
expect(source).toContain("...revisions.value.filter((item) => item.id !== restoredForDisplay.id)");
|
expect(source).toContain("...revisions.value.filter((item) => item.id !== restoredForDisplay.id)");
|
||||||
expect(source).toContain("const [revisionResponse, fileResponse] = await Promise.all([");
|
expect(source).toContain("const [revisionResponse] = await Promise.all([");
|
||||||
expect(source).toContain("if (refreshedFile) historyFile.value = refreshedFile");
|
expect(source).toContain("if (refreshedFile) historyFile.value = refreshedFile");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,7 +92,7 @@ describe("CollaborationLibrary UI contract", () => {
|
|||||||
expect(source).toContain("scheduleHistoryRefresh();");
|
expect(source).toContain("scheduleHistoryRefresh();");
|
||||||
expect(source).toContain("refreshHistory({ silent: true })");
|
expect(source).toContain("refreshHistory({ silent: true })");
|
||||||
expect(source).toContain("const [revisionResponse, fileResponse] = await Promise.all([");
|
expect(source).toContain("const [revisionResponse, fileResponse] = await Promise.all([");
|
||||||
expect(historyApiSource).toContain("fetchCollaborationFile(target.studyId, id)");
|
expect(source).toContain("fetchCollaborationFile(requireStudyId(), fileId)");
|
||||||
expect(source).toContain("historyFile.value = fileResponse.data");
|
expect(source).toContain("historyFile.value = fileResponse.data");
|
||||||
expect(source).toContain("requestSequence !== historyRequestSequence");
|
expect(source).toContain("requestSequence !== historyRequestSequence");
|
||||||
});
|
});
|
||||||
@@ -124,11 +119,11 @@ describe("CollaborationLibrary UI contract", () => {
|
|||||||
expect(source).toContain("nameRevision(row)");
|
expect(source).toContain("nameRevision(row)");
|
||||||
expect(source).toContain("previewRevision(row)");
|
expect(source).toContain("previewRevision(row)");
|
||||||
expect(source).toContain("saveRevisionAs(row)");
|
expect(source).toContain("saveRevisionAs(row)");
|
||||||
expect(source).toContain('"LedgerRevisionPreview" : "OfficeCollaborationRevisionPreview"');
|
expect(source).toContain('name: "OfficeCollaborationRevisionPreview"');
|
||||||
expect(source).toContain('window.open(previewRoute.href, "_blank", "noopener,noreferrer")');
|
expect(source).toContain('window.open(previewRoute.href, "_blank", "noopener,noreferrer")');
|
||||||
expect(source).not.toContain("historyDialogVisible.value = false");
|
expect(source).not.toContain("historyDialogVisible.value = false");
|
||||||
expect(source).toContain("copyCollaborationRevision");
|
expect(source).toContain("copyCollaborationRevision");
|
||||||
expect(historyApiSource).toContain("updateCollaborationRevision");
|
expect(source).toContain("updateCollaborationRevision");
|
||||||
expect(source).toContain('v-model="revisionSaveAsDialogVisible"');
|
expect(source).toContain('v-model="revisionSaveAsDialogVisible"');
|
||||||
expect(source).toContain("<CollaborationSaveAsDialog");
|
expect(source).toContain("<CollaborationSaveAsDialog");
|
||||||
expect(source).toContain(':initial-folder-id="revisionSaveAsFolderId"');
|
expect(source).toContain(':initial-folder-id="revisionSaveAsFolderId"');
|
||||||
@@ -139,7 +134,7 @@ describe("CollaborationLibrary UI contract", () => {
|
|||||||
expect(source).not.toContain('PERMISSION_CHANGE: "权限设置"');
|
expect(source).not.toContain('PERMISSION_CHANGE: "权限设置"');
|
||||||
expect(source).toContain('command="delete"');
|
expect(source).toContain('command="delete"');
|
||||||
expect(source).toContain("删除版本");
|
expect(source).toContain("删除版本");
|
||||||
expect(source).toContain('v-if="target.scope === \'project\' && historyFile?.can_manage" command="delete"');
|
expect(source).toContain('v-if="historyFile?.can_manage" command="delete"');
|
||||||
expect(source).toContain("deleteCollaborationRevision");
|
expect(source).toContain("deleteCollaborationRevision");
|
||||||
expect(source).toContain("revision.id === historyFile.value.current_revision_id");
|
expect(source).toContain("revision.id === historyFile.value.current_revision_id");
|
||||||
expect(source).toContain("height: min(760px, 82vh)");
|
expect(source).toContain("height: min(760px, 82vh)");
|
||||||
@@ -158,7 +153,7 @@ describe("CollaborationLibrary UI contract", () => {
|
|||||||
expect(source).toContain("<span>管理协作者</span>");
|
expect(source).toContain("<span>管理协作者</span>");
|
||||||
expect(source).toContain('class="access-member-identity"');
|
expect(source).toContain('class="access-member-identity"');
|
||||||
expect(source).toContain('class="access-member-role"');
|
expect(source).toContain('class="access-member-role"');
|
||||||
expect(source).toContain('class="access-member-owner">{{ member.protected_label || "所有者" }}</span>');
|
expect(source).toContain('class="access-member-owner">所有者</span>');
|
||||||
expect(source).not.toContain('<el-table :data="members"');
|
expect(source).not.toContain('<el-table :data="members"');
|
||||||
expect(source).toContain('v-model="memberInviteDialogVisible"');
|
expect(source).toContain('v-model="memberInviteDialogVisible"');
|
||||||
expect(source).toContain('title="管理协作者"');
|
expect(source).toContain('title="管理协作者"');
|
||||||
@@ -167,7 +162,7 @@ describe("CollaborationLibrary UI contract", () => {
|
|||||||
expect(source).not.toContain('member-picker-group--recent');
|
expect(source).not.toContain('member-picker-group--recent');
|
||||||
expect(source).not.toContain('member-picker-recent-empty');
|
expect(source).not.toContain('member-picker-recent-empty');
|
||||||
expect(source).not.toContain('最近选择的账号将显示在这里');
|
expect(source).not.toContain('最近选择的账号将显示在这里');
|
||||||
expect(source).toContain('role="listbox" :aria-label="`可添加的${directoryLabel}`"');
|
expect(source).toContain('role="listbox" aria-label="可添加的项目成员"');
|
||||||
expect(source).toContain("memberForm.user_ids.includes(candidate.user_id)");
|
expect(source).toContain("memberForm.user_ids.includes(candidate.user_id)");
|
||||||
expect(source).toContain("toggleMemberCandidate(candidate.user_id)");
|
expect(source).toContain("toggleMemberCandidate(candidate.user_id)");
|
||||||
expect(source).toContain("selectedMemberCandidates.length");
|
expect(source).toContain("selectedMemberCandidates.length");
|
||||||
@@ -196,7 +191,7 @@ describe("CollaborationLibrary UI contract", () => {
|
|||||||
expect(source).toContain("允许申请编辑权限");
|
expect(source).toContain("允许申请编辑权限");
|
||||||
expect(source).toContain("handleEditRequestPermissionChange");
|
expect(source).toContain("handleEditRequestPermissionChange");
|
||||||
expect(source).toContain("待处理申请");
|
expect(source).toContain("待处理申请");
|
||||||
expect(accessApiSource).toContain("resolveCollaborationEditRequest");
|
expect(source).toContain("resolveCollaborationEditRequest");
|
||||||
expect(source).toContain("notifyProjectNotificationsChanged");
|
expect(source).toContain("notifyProjectNotificationsChanged");
|
||||||
expect(source).toContain("route.query.editRequestFile");
|
expect(source).toContain("route.query.editRequestFile");
|
||||||
expect(source).toContain("openEditRequestNotificationTarget");
|
expect(source).toContain("openEditRequestNotificationTarget");
|
||||||
@@ -226,7 +221,7 @@ describe("CollaborationLibrary UI contract", () => {
|
|||||||
expect(source).toContain(':disabled="!selectedTransferCandidate"');
|
expect(source).toContain(':disabled="!selectedTransferCandidate"');
|
||||||
expect(source).toContain('>确定</el-button>');
|
expect(source).toContain('>确定</el-button>');
|
||||||
expect(source).not.toContain('accessPanel === "transfer"');
|
expect(source).not.toContain('accessPanel === "transfer"');
|
||||||
expect(accessApiSource).toContain("transferCollaborationOwnership");
|
expect(source).toContain("transferCollaborationOwnership");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves permission switches silently while retaining failure feedback", () => {
|
it("saves permission switches silently while retaining failure feedback", () => {
|
||||||
@@ -271,8 +266,8 @@ describe("CollaborationLibrary UI contract", () => {
|
|||||||
expect(source).not.toContain("同时约束账号授权和匿名访问");
|
expect(source).not.toContain("同时约束账号授权和匿名访问");
|
||||||
expect(source).toContain('@change="persistShareSettings()"');
|
expect(source).toContain('@change="persistShareSettings()"');
|
||||||
expect(source).not.toContain("保存链接设置");
|
expect(source).not.toContain("保存链接设置");
|
||||||
expect(accessApiSource).toContain("fetchCollaborationShareLink");
|
expect(source).toContain("fetchCollaborationShareLink");
|
||||||
expect(accessApiSource).toContain("updateCollaborationShareLink");
|
expect(source).toContain("updateCollaborationShareLink");
|
||||||
expect(source).not.toContain("regenerateCollaborationShareLink");
|
expect(source).not.toContain("regenerateCollaborationShareLink");
|
||||||
expect(source).not.toContain("重新生成链接");
|
expect(source).not.toContain("重新生成链接");
|
||||||
expect(source).not.toContain("关闭后重新开启仍使用同一地址");
|
expect(source).not.toContain("关闭后重新开启仍使用同一地址");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@
|
|||||||
'is-fullscreen': webFullscreenActive,
|
'is-fullscreen': webFullscreenActive,
|
||||||
'is-fullscreen-toolbar-visible': fullscreenToolbarVisible,
|
'is-fullscreen-toolbar-visible': fullscreenToolbarVisible,
|
||||||
'is-macos-desktop': isMacDesktop,
|
'is-macos-desktop': isMacDesktop,
|
||||||
'is-ledger-workspace': isLedgerWorkspace,
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -84,7 +83,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<CollaborationSaveAsDialog
|
<CollaborationSaveAsDialog
|
||||||
v-if="!isLedgerWorkspace"
|
|
||||||
v-model="saveAsDialogVisible"
|
v-model="saveAsDialogVisible"
|
||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
:initial-title="saveAsTitle"
|
:initial-title="saveAsTitle"
|
||||||
@@ -119,8 +117,6 @@ import {
|
|||||||
recordCollaborationDownload,
|
recordCollaborationDownload,
|
||||||
} from "../../api/collaboration";
|
} from "../../api/collaboration";
|
||||||
import { useStudyStore } from "../../store/study";
|
import { useStudyStore } from "../../store/study";
|
||||||
import { checkLedgerDownload, createLedgerEditRequest, fetchLedger, fetchLedgerEditorConfig } from "../../api/ledgers";
|
|
||||||
import type { Ledger } from "../../types/ledger";
|
|
||||||
import {
|
import {
|
||||||
DESKTOP_SERVER_URL_CHANGED_EVENT,
|
DESKTOP_SERVER_URL_CHANGED_EVENT,
|
||||||
getRuntimePlatform,
|
getRuntimePlatform,
|
||||||
@@ -146,10 +142,7 @@ const router = useRouter();
|
|||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const workspaceTaskController = inject(workspaceTaskControllerKey, null);
|
const workspaceTaskController = inject(workspaceTaskControllerKey, null);
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
const isLedgerWorkspace = computed(() => route.meta.ledgerWorkspace === true);
|
const file = ref<CollaborationFile | null>(null);
|
||||||
type WorkspaceFile = Pick<CollaborationFile, "title" | "can_edit"> & Partial<Pick<CollaborationFile, "folder_id" | "edit_request_status" | "can_request_edit">>;
|
|
||||||
const file = ref<WorkspaceFile | null>(null);
|
|
||||||
const ledgerSnapshot = ref<Ledger | null>(null);
|
|
||||||
const editorConfig = ref<CollaborationEditorConfig | null>(null);
|
const editorConfig = ref<CollaborationEditorConfig | null>(null);
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const warningMessage = ref("");
|
const warningMessage = ref("");
|
||||||
@@ -173,8 +166,6 @@ const isMacDesktop = isTauriRuntime() && getRuntimePlatform() === "macos";
|
|||||||
let mounted = false;
|
let mounted = false;
|
||||||
let webFullscreenUnlisten: (() => void) | null = null;
|
let webFullscreenUnlisten: (() => void) | null = null;
|
||||||
let fullscreenToolbarHideTimer: number | undefined;
|
let fullscreenToolbarHideTimer: number | undefined;
|
||||||
let ledgerCheckTimer: number | undefined;
|
|
||||||
let checkingLedger = false;
|
|
||||||
|
|
||||||
watch(downloadDialogVisible, (visible) => {
|
watch(downloadDialogVisible, (visible) => {
|
||||||
if (!visible && !downloading.value) {
|
if (!visible && !downloading.value) {
|
||||||
@@ -287,7 +278,7 @@ const destroyEditor = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadWorkspace = async () => {
|
const loadWorkspace = async () => {
|
||||||
if ((!isLedgerWorkspace.value && !studyId.value) || !fileId.value) return;
|
if (!studyId.value || !fileId.value) return;
|
||||||
const sequence = requestSequence.value + 1;
|
const sequence = requestSequence.value + 1;
|
||||||
requestSequence.value = sequence;
|
requestSequence.value = sequence;
|
||||||
destroyEditor();
|
destroyEditor();
|
||||||
@@ -296,19 +287,17 @@ const loadWorkspace = async () => {
|
|||||||
warningMessage.value = "";
|
warningMessage.value = "";
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
status.value = "loading";
|
status.value = "loading";
|
||||||
ledgerSnapshot.value = null;
|
|
||||||
try {
|
try {
|
||||||
const [fileResponse, configResponse] = await Promise.all([
|
const [fileResponse, configResponse] = await Promise.all([
|
||||||
isLedgerWorkspace.value ? fetchLedger(fileId.value) : fetchCollaborationFile(studyId.value, fileId.value),
|
fetchCollaborationFile(studyId.value, fileId.value),
|
||||||
isLedgerWorkspace.value ? fetchLedgerEditorConfig(fileId.value) : fetchCollaborationEditorConfig(studyId.value, fileId.value),
|
fetchCollaborationEditorConfig(studyId.value, fileId.value),
|
||||||
]);
|
]);
|
||||||
if (requestSequence.value !== sequence) return;
|
if (requestSequence.value !== sequence) return;
|
||||||
if (configResponse.data.host_path !== ONLYOFFICE_HOST_PATH) throw new Error("ONLYOFFICE 宿主页配置不合法");
|
if (configResponse.data.host_path !== ONLYOFFICE_HOST_PATH) throw new Error("ONLYOFFICE 宿主页配置不合法");
|
||||||
file.value = fileResponse.data;
|
file.value = fileResponse.data;
|
||||||
editorConfig.value = configResponse.data;
|
editorConfig.value = configResponse.data;
|
||||||
viewerSequence.value += 1;
|
viewerSequence.value += 1;
|
||||||
if (isLedgerWorkspace.value) ledgerSnapshot.value = fileResponse.data as Ledger;
|
study.setViewContext({ pageTitle: configResponse.data.file_name, objectType: "在线协作" });
|
||||||
else study.setViewContext({ pageTitle: configResponse.data.file_name, objectType: "在线协作" });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestSequence.value !== sequence) return;
|
if (requestSequence.value !== sequence) return;
|
||||||
errorMessage.value = await errorForResponse(error);
|
errorMessage.value = await errorForResponse(error);
|
||||||
@@ -318,29 +307,6 @@ const loadWorkspace = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkLedgerSession = async () => {
|
|
||||||
if (!isLedgerWorkspace.value || !editorConfig.value || !ledgerSnapshot.value || loading.value || checkingLedger) return;
|
|
||||||
const sequence = requestSequence.value;
|
|
||||||
checkingLedger = true;
|
|
||||||
try {
|
|
||||||
const { data } = await fetchLedger(fileId.value);
|
|
||||||
if (sequence !== requestSequence.value) return;
|
|
||||||
if (data.generation !== ledgerSnapshot.value?.generation || data.role !== ledgerSnapshot.value.role) {
|
|
||||||
destroyEditor();
|
|
||||||
errorMessage.value = "台账权限或内容版本已更新,请重新打开台账";
|
|
||||||
status.value = "error";
|
|
||||||
} else {
|
|
||||||
file.value = data;
|
|
||||||
if (editorConfig.value) editorConfig.value.can_request_edit = data.can_request_edit;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (sequence !== requestSequence.value) return;
|
|
||||||
destroyEditor();
|
|
||||||
errorMessage.value = await getApiErrorMessage(error, "无法确认台账访问权限,请检查连接后重试");
|
|
||||||
status.value = "error";
|
|
||||||
} finally { checkingLedger = false; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const eventMessage = (detail: Record<string, unknown>) => {
|
const eventMessage = (detail: Record<string, unknown>) => {
|
||||||
for (const key of ["message", "errorDescription", "warningDescription"] as const) {
|
for (const key of ["message", "errorDescription", "warningDescription"] as const) {
|
||||||
const value = detail[key];
|
const value = detail[key];
|
||||||
@@ -366,8 +332,7 @@ const requestEditRights = async () => {
|
|||||||
if (requestingEdit.value || accessMode.value === "edit" || file.value?.edit_request_status === "PENDING") return;
|
if (requestingEdit.value || accessMode.value === "edit" || file.value?.edit_request_status === "PENDING") return;
|
||||||
requestingEdit.value = true;
|
requestingEdit.value = true;
|
||||||
try {
|
try {
|
||||||
if (isLedgerWorkspace.value) await createLedgerEditRequest(fileId.value);
|
await createCollaborationEditRequest(studyId.value, fileId.value);
|
||||||
else await createCollaborationEditRequest(studyId.value, fileId.value);
|
|
||||||
if (file.value) {
|
if (file.value) {
|
||||||
file.value = { ...file.value, can_request_edit: false, edit_request_status: "PENDING" };
|
file.value = { ...file.value, can_request_edit: false, edit_request_status: "PENDING" };
|
||||||
}
|
}
|
||||||
@@ -444,7 +409,6 @@ const savePendingDownload = async () => {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isLedgerWorkspace.value) await checkLedgerDownload(fileId.value, extension);
|
|
||||||
const result = await saveFileWithFeedback(
|
const result = await saveFileWithFeedback(
|
||||||
{ suggestedName, mimeType: payload.mimeType, data: payload.data },
|
{ suggestedName, mimeType: payload.mimeType, data: payload.data },
|
||||||
{
|
{
|
||||||
@@ -460,7 +424,7 @@ const savePendingDownload = async () => {
|
|||||||
pendingDownload.value = null;
|
pendingDownload.value = null;
|
||||||
downloadTitle.value = "";
|
downloadTitle.value = "";
|
||||||
try {
|
try {
|
||||||
if (!isLedgerWorkspace.value) await recordCollaborationDownload(studyId.value, fileId.value, extension);
|
await recordCollaborationDownload(studyId.value, fileId.value, extension);
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.warning("文件已下载,但下载审计记录失败");
|
ElMessage.warning("文件已下载,但下载审计记录失败");
|
||||||
}
|
}
|
||||||
@@ -500,10 +464,6 @@ const handleServerChange = () => {
|
|||||||
|
|
||||||
const goBack = async () => {
|
const goBack = async () => {
|
||||||
await exitWorkspaceFullscreen();
|
await exitWorkspaceFullscreen();
|
||||||
if (isLedgerWorkspace.value) {
|
|
||||||
await router.push(isTauriRuntime() ? "/desktop/project-entry" : "/workbench");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (workspaceTaskController?.closeTransientTask(route.path)) return;
|
if (workspaceTaskController?.closeTransientTask(route.path)) return;
|
||||||
await router.push("/knowledge/collaboration");
|
await router.push("/knowledge/collaboration");
|
||||||
};
|
};
|
||||||
@@ -514,8 +474,6 @@ onMounted(() => {
|
|||||||
webFullscreenUnlisten = listenWebFullscreenChange(syncWebFullscreenState);
|
webFullscreenUnlisten = listenWebFullscreenChange(syncWebFullscreenState);
|
||||||
void refreshWebFullscreenState().then(syncWebFullscreenState).catch(() => {});
|
void refreshWebFullscreenState().then(syncWebFullscreenState).catch(() => {});
|
||||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
|
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
|
||||||
ledgerCheckTimer = window.setInterval(() => { void checkLedgerSession(); }, 15000);
|
|
||||||
window.addEventListener("focus", checkLedgerSession);
|
|
||||||
void loadWorkspace();
|
void loadWorkspace();
|
||||||
});
|
});
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
@@ -529,17 +487,14 @@ onDeactivated(() => {
|
|||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
});
|
});
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (ledgerCheckTimer !== undefined) window.clearInterval(ledgerCheckTimer);
|
|
||||||
window.removeEventListener("focus", checkLedgerSession);
|
|
||||||
cancelFullscreenToolbarHide();
|
cancelFullscreenToolbarHide();
|
||||||
webFullscreenUnlisten?.();
|
webFullscreenUnlisten?.();
|
||||||
webFullscreenUnlisten = null;
|
webFullscreenUnlisten = null;
|
||||||
void exitWorkspaceFullscreen();
|
void exitWorkspaceFullscreen();
|
||||||
destroyEditor();
|
destroyEditor();
|
||||||
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
|
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, handleServerChange);
|
||||||
if (!isLedgerWorkspace.value) study.setViewContext(null);
|
study.setViewContext(null);
|
||||||
});
|
});
|
||||||
watch(fileId, () => { if (mounted) void loadWorkspace(); });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -556,7 +511,6 @@ watch(fileId, () => { if (mounted) void loadWorkspace(); });
|
|||||||
background: #f3f5f8;
|
background: #f3f5f8;
|
||||||
}
|
}
|
||||||
.collaboration-workspace.is-fullscreen { grid-template-rows: minmax(0, 1fr); }
|
.collaboration-workspace.is-fullscreen { grid-template-rows: minmax(0, 1fr); }
|
||||||
.collaboration-workspace.is-ledger-workspace { height: 100dvh; }
|
|
||||||
.collaboration-workspace__fullscreen-hotzone {
|
.collaboration-workspace__fullscreen-hotzone {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { flushPromises, mount } from "@vue/test-utils";
|
|
||||||
import Workspace from "./CollaborationWorkspace.vue";
|
|
||||||
import { createLedgerEditRequest, fetchLedger, fetchLedgerEditorConfig } from "../../api/ledgers";
|
|
||||||
import { createCollaborationEditRequest, fetchCollaborationFile } from "../../api/collaboration";
|
|
||||||
|
|
||||||
const state = vi.hoisted(() => ({ push: vi.fn(), setViewContext: vi.fn() }));
|
|
||||||
vi.mock("vue-router", () => ({
|
|
||||||
useRoute: () => ({ meta: { ledgerWorkspace: true }, params: { fileId: "ledger-a" }, path: "/ledgers/ledger-a" }),
|
|
||||||
useRouter: () => ({ push: state.push }),
|
|
||||||
}));
|
|
||||||
vi.mock("../../store/study", () => ({ useStudyStore: () => ({ currentStudy: null, setViewContext: state.setViewContext }) }));
|
|
||||||
vi.mock("../../utils/permission", () => ({ usePermission: () => ({ can: () => false }) }));
|
|
||||||
vi.mock("../../utils/fileTaskFeedback", () => ({ saveFileWithFeedback: vi.fn() }));
|
|
||||||
vi.mock("../../api/ledgers", () => ({ fetchLedger: vi.fn(), fetchLedgerEditorConfig: vi.fn(), checkLedgerDownload: vi.fn(), createLedgerEditRequest: vi.fn() }));
|
|
||||||
vi.mock("../../api/collaboration", () => ({
|
|
||||||
fetchCollaborationFile: vi.fn(), fetchCollaborationEditorConfig: vi.fn(), createCollaborationEditRequest: vi.fn(),
|
|
||||||
importCollaborationFile: vi.fn(), recordCollaborationDownload: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("../../runtime", () => ({
|
|
||||||
DESKTOP_SERVER_URL_CHANGED_EVENT: "server-changed", getRuntimePlatform: () => "web", isTauriRuntime: () => false,
|
|
||||||
isWebFullscreenActive: () => false, isWebFullscreenAvailable: () => false, listenWebFullscreenChange: () => () => {},
|
|
||||||
ONLYOFFICE_HOST_PATH: "/onlyoffice-host.html", prepareSaveFile: vi.fn(),
|
|
||||||
refreshWebFullscreenState: async () => {}, toggleWebFullscreen: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("../../components/collaboration/CollaborationSaveAsDialog.vue", () => ({ default: { template: '<div />' } }));
|
|
||||||
vi.mock("../../components/collaboration/CollaborationDownloadDialog.vue", () => ({ default: { template: '<div />' } }));
|
|
||||||
vi.mock("../../components/OnlyOfficeViewer.vue", () => ({ default: { name: "OnlyOfficeViewer", props: ["config"], template: '<div data-test="editor" />' } }));
|
|
||||||
|
|
||||||
const ledger = { id: "ledger-a", title: "医学事务部临床运营项目编号.xlsx", can_edit: true, role: "EDITOR", generation: 4 };
|
|
||||||
let wrapper: ReturnType<typeof mount> | undefined;
|
|
||||||
const render = () => {
|
|
||||||
wrapper = mount(Workspace, { global: { stubs: {
|
|
||||||
ElButton: { template: '<button><slot /></button>' }, ElIcon: { template: '<span><slot /></span>' },
|
|
||||||
ElTooltip: { template: '<span><slot /></span>' },
|
|
||||||
} } });
|
|
||||||
return wrapper;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("ledger collaboration workspace", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.mocked(fetchLedger).mockResolvedValue({ data: ledger } as any);
|
|
||||||
vi.mocked(fetchLedgerEditorConfig).mockResolvedValue({ data: {
|
|
||||||
file_name: ledger.title, host_path: "/onlyoffice-host.html", access_mode: "edit", can_download: false,
|
|
||||||
can_save_as: false, can_request_edit: false, config: {},
|
|
||||||
} } as any);
|
|
||||||
});
|
|
||||||
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.useRealTimers(); });
|
|
||||||
it("uses global APIs and returns to the workbench with no project context", async () => {
|
|
||||||
const page = render();
|
|
||||||
await flushPromises();
|
|
||||||
expect(page.find('[data-test="editor"]').exists()).toBe(true);
|
|
||||||
expect(fetchLedgerEditorConfig).toHaveBeenCalledWith("ledger-a");
|
|
||||||
expect(fetchCollaborationFile).not.toHaveBeenCalled();
|
|
||||||
expect(state.setViewContext).not.toHaveBeenCalled();
|
|
||||||
await page.get(".collaboration-workspace__back").trigger("click");
|
|
||||||
expect(state.push).toHaveBeenCalledWith("/workbench");
|
|
||||||
});
|
|
||||||
it("destroys the old editor when permissions or generation change", async () => {
|
|
||||||
const page = render();
|
|
||||||
await flushPromises();
|
|
||||||
vi.mocked(fetchLedger).mockResolvedValue({ data: { ...ledger, generation: 5 } } as any);
|
|
||||||
await vi.advanceTimersByTimeAsync(15000);
|
|
||||||
await flushPromises();
|
|
||||||
expect(page.find('[data-test="editor"]').exists()).toBe(false);
|
|
||||||
expect(page.text()).toContain("台账权限或内容版本已更新");
|
|
||||||
});
|
|
||||||
it("removes the editor if access can no longer be verified", async () => {
|
|
||||||
const page = render();
|
|
||||||
await flushPromises();
|
|
||||||
vi.mocked(fetchLedger).mockRejectedValue(new Error("denied"));
|
|
||||||
await vi.advanceTimersByTimeAsync(15000);
|
|
||||||
await flushPromises();
|
|
||||||
expect(page.find('[data-test="editor"]').exists()).toBe(false);
|
|
||||||
expect(page.text()).toContain("无法打开协作文件");
|
|
||||||
});
|
|
||||||
it("submits a viewer edit request without project APIs and refreshes its pending status", async () => {
|
|
||||||
const viewer = { ...ledger, role: "VIEWER", can_edit: false, can_request_edit: true };
|
|
||||||
vi.mocked(fetchLedger).mockResolvedValue({ data: viewer } as any);
|
|
||||||
vi.mocked(fetchLedgerEditorConfig).mockResolvedValue({ data: {
|
|
||||||
file_name: ledger.title, host_path: "/onlyoffice-host.html", access_mode: "view", can_request_edit: true, config: {},
|
|
||||||
} } as any);
|
|
||||||
vi.mocked(createLedgerEditRequest).mockResolvedValue({ data: { status: "PENDING" } } as any);
|
|
||||||
const page = render();
|
|
||||||
await flushPromises();
|
|
||||||
await page.findAll("button").find((button) => button.text() === "申请编辑权限")!.trigger("click");
|
|
||||||
await flushPromises();
|
|
||||||
expect(createLedgerEditRequest).toHaveBeenCalledWith("ledger-a");
|
|
||||||
expect(createCollaborationEditRequest).not.toHaveBeenCalled();
|
|
||||||
expect(page.text()).toContain("编辑权限申请处理中");
|
|
||||||
vi.mocked(fetchLedger).mockResolvedValue({ data: { ...viewer, edit_request_status: "REJECTED" } } as any);
|
|
||||||
await vi.advanceTimersByTimeAsync(15000);
|
|
||||||
await flushPromises();
|
|
||||||
expect(page.text()).not.toContain("编辑权限申请处理中");
|
|
||||||
expect(page.find('[data-test="editor"]').exists()).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,8 +3,8 @@ FROM ${ONLYOFFICE_IMAGE}
|
|||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
# Use redistributable Noto CJK fonts for Chinese document preview. Do not add
|
# Use redistributable Noto CJK fonts for Chinese document preview. Proprietary
|
||||||
# deployment-supplied proprietary Microsoft or Zhongyi fonts to this build.
|
# Microsoft fonts must be supplied separately by an authorized deployment.
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk \
|
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fonts-noto-cjk \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
+7
-43
@@ -62,9 +62,9 @@ ${C_BOLD}环境:${C_RESET}
|
|||||||
${C_CYAN}release${C_RESET} 生产环境(强制 HTTPS,自动生成密钥对)
|
${C_CYAN}release${C_RESET} 生产环境(强制 HTTPS,自动生成密钥对)
|
||||||
|
|
||||||
${C_BOLD}选项:${C_RESET}
|
${C_BOLD}选项:${C_RESET}
|
||||||
${C_WHITE}--base-url <url>${C_RESET} 对外访问基址(同时用于健康检查与 ONLYOFFICE 回调校验)
|
${C_WHITE}--base-url <url>${C_RESET} 健康检查使用的访问地址
|
||||||
dev/main 默认 http://127.0.0.1:8888
|
dev/main 默认 http://127.0.0.1:8888
|
||||||
已有环境优先复用 .env;首次 release 安装必须提供
|
release 必须通过此参数或交互输入提供
|
||||||
${C_WHITE}--yes${C_RESET} 跳过所有交互确认,适合 CI/CD 自动化执行
|
${C_WHITE}--yes${C_RESET} 跳过所有交互确认,适合 CI/CD 自动化执行
|
||||||
${C_WHITE}--skip-build${C_RESET} 跳过镜像构建,仍会启动容器(docker compose up -d)
|
${C_WHITE}--skip-build${C_RESET} 跳过镜像构建,仍会启动容器(docker compose up -d)
|
||||||
${C_WHITE}--skip-migrate${C_RESET} 跳过数据库迁移(alembic upgrade head)
|
${C_WHITE}--skip-migrate${C_RESET} 跳过数据库迁移(alembic upgrade head)
|
||||||
@@ -179,25 +179,16 @@ generate_onlyoffice_instance_id() {
|
|||||||
|
|
||||||
# ── 地址解析 ─────────────────────────────────
|
# ── 地址解析 ─────────────────────────────────
|
||||||
resolve_base_url() {
|
resolve_base_url() {
|
||||||
if [[ -z "$BASE_URL" && -f "$ENV_FILE" ]]; then
|
|
||||||
BASE_URL="$(read_env_value CTMS_BASE_URL || true)"
|
|
||||||
[[ -n "$BASE_URL" ]] || BASE_URL="$(read_env_value FRONTEND_PUBLIC_URL || true)"
|
|
||||||
fi
|
|
||||||
[[ -z "$BASE_URL" ]] && BASE_URL="$(default_base_url)"
|
[[ -z "$BASE_URL" ]] && BASE_URL="$(default_base_url)"
|
||||||
|
|
||||||
if [[ "$TARGET_ENV" == "release" && -z "$BASE_URL" && "$ASSUME_YES" -eq 0 ]]; then
|
if [[ "$TARGET_ENV" == "release" && -z "$BASE_URL" && "$ASSUME_YES" -eq 0 ]]; then
|
||||||
printf ' %s▸%s 请输入 release 对外访问基址(例如 https://ctms.example.com): ' \
|
printf ' %s▸%s 请输入 release 健康检查域名(例如 https://ctms.example.com): ' \
|
||||||
"${C_YELLOW}${C_BOLD}" "${C_RESET}"
|
"${C_YELLOW}${C_BOLD}" "${C_RESET}"
|
||||||
read -r BASE_URL || true
|
read -r BASE_URL || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[[ -n "$BASE_URL" ]] || fail "release 环境必须通过 --base-url 或交互输入提供 HTTPS 地址"
|
[[ -n "$BASE_URL" ]] || fail "release 环境必须通过 --base-url 或交互输入提供 HTTPS 地址"
|
||||||
|
|
||||||
BASE_URL="${BASE_URL%/}"
|
|
||||||
if [[ ! "$BASE_URL" =~ ^https?://[^/?#]+$ || "$BASE_URL" == *"@"* ]]; then
|
|
||||||
fail "对外访问基址必须是仅包含协议和主机的 HTTP(S) origin,例如 https://ctms.example.com"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$TARGET_ENV" == "release" && "$BASE_URL" != https://* ]]; then
|
if [[ "$TARGET_ENV" == "release" && "$BASE_URL" != https://* ]]; then
|
||||||
fail "release 环境的访问地址必须使用 HTTPS"
|
fail "release 环境的访问地址必须使用 HTTPS"
|
||||||
fi
|
fi
|
||||||
@@ -231,7 +222,7 @@ confirm_install() {
|
|||||||
row "目标环境" "$TARGET_ENV" "${CC_INFO}${C_BOLD}"
|
row "目标环境" "$TARGET_ENV" "${CC_INFO}${C_BOLD}"
|
||||||
row "运行模式" "$runtime_env"
|
row "运行模式" "$runtime_env"
|
||||||
row "Compose 项目" "$project_name"
|
row "Compose 项目" "$project_name"
|
||||||
row "对外访问基址" "$BASE_URL" "${CC_INFO}"
|
row "健康检查地址" "$BASE_URL" "${CC_INFO}"
|
||||||
row ".env 文件" "$env_status"
|
row ".env 文件" "$env_status"
|
||||||
row "pg_data 目录" "$pg_status"
|
row "pg_data 目录" "$pg_status"
|
||||||
row "ONLYOFFICE" "标准组件(自动安装)" "${CC_INFO}"
|
row "ONLYOFFICE" "标准组件(自动安装)" "${CC_INFO}"
|
||||||
@@ -385,23 +376,6 @@ run_backend_init() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
check_onlyoffice_active_sessions() {
|
|
||||||
step "检查 ONLYOFFICE 在线编辑会话"
|
|
||||||
local running output
|
|
||||||
running="$(compose_cmd ps --services --filter status=running 2>/dev/null || true)"
|
|
||||||
if ! printf '%s\n' "$running" | grep -qx "onlyoffice"; then
|
|
||||||
ok "ONLYOFFICE 尚未运行,跳过在线编辑会话检查"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if output="$(compose_cmd run --rm --no-deps backend-init \
|
|
||||||
python scripts/check_onlyoffice_active_sessions.py 2>&1)"; then
|
|
||||||
ok "$output"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
printf '%s\n' "$output" >&2
|
|
||||||
fail "部署前置检查未通过;为避免在线文件保存失败,尚未重启任何业务服务"
|
|
||||||
}
|
|
||||||
|
|
||||||
run_build_and_start() {
|
run_build_and_start() {
|
||||||
if [[ "$SKIP_BUILD" -eq 1 ]]; then
|
if [[ "$SKIP_BUILD" -eq 1 ]]; then
|
||||||
step "启动服务(跳过镜像构建)"
|
step "启动服务(跳过镜像构建)"
|
||||||
@@ -449,7 +423,7 @@ check_container_status() {
|
|||||||
|
|
||||||
check_backend_environment() {
|
check_backend_environment() {
|
||||||
step "校验后端运行时环境变量"
|
step "校验后端运行时环境变量"
|
||||||
local expected_env="$1" expected_key_id="$2" expect_rsa="$3" expected_public_url="$4"
|
local expected_env="$1" expected_key_id="$2" expect_rsa="$3"
|
||||||
local check_code
|
local check_code
|
||||||
check_code=$(cat <<'PY'
|
check_code=$(cat <<'PY'
|
||||||
import os, sys
|
import os, sys
|
||||||
@@ -459,7 +433,6 @@ from app.core.login_crypto import _normalize_pem
|
|||||||
expected_env = os.environ["EXPECTED_ENV"]
|
expected_env = os.environ["EXPECTED_ENV"]
|
||||||
expected_key_id = os.environ["EXPECTED_KEY_ID"]
|
expected_key_id = os.environ["EXPECTED_KEY_ID"]
|
||||||
expect_rsa = os.environ["EXPECT_RSA"] == "1"
|
expect_rsa = os.environ["EXPECT_RSA"] == "1"
|
||||||
expected_public_url = os.environ["EXPECTED_PUBLIC_URL"].rstrip("/")
|
|
||||||
|
|
||||||
if settings.ENV != expected_env:
|
if settings.ENV != expected_env:
|
||||||
sys.exit(f"ENV 不匹配: {settings.ENV} != {expected_env}")
|
sys.exit(f"ENV 不匹配: {settings.ENV} != {expected_env}")
|
||||||
@@ -475,11 +448,6 @@ if settings.ONLYOFFICE_JWT_SECRET == settings.JWT_SECRET_KEY:
|
|||||||
sys.exit("ONLYOFFICE_JWT_SECRET 不得复用登录 JWT 密钥")
|
sys.exit("ONLYOFFICE_JWT_SECRET 不得复用登录 JWT 密钥")
|
||||||
if not settings.ONLYOFFICE_INSTANCE_ID:
|
if not settings.ONLYOFFICE_INSTANCE_ID:
|
||||||
sys.exit("ONLYOFFICE_INSTANCE_ID 未配置")
|
sys.exit("ONLYOFFICE_INSTANCE_ID 未配置")
|
||||||
if settings.FRONTEND_PUBLIC_URL.rstrip("/") != expected_public_url:
|
|
||||||
sys.exit(
|
|
||||||
"FRONTEND_PUBLIC_URL 不匹配;ONLYOFFICE 公开缓存地址会被保存回调拒绝: "
|
|
||||||
f"{settings.FRONTEND_PUBLIC_URL} != {expected_public_url}"
|
|
||||||
)
|
|
||||||
if expect_rsa:
|
if expect_rsa:
|
||||||
if not settings.LOGIN_RSA_PRIVATE_KEY:
|
if not settings.LOGIN_RSA_PRIVATE_KEY:
|
||||||
sys.exit("LOGIN_RSA_PRIVATE_KEY 未配置")
|
sys.exit("LOGIN_RSA_PRIVATE_KEY 未配置")
|
||||||
@@ -494,7 +462,6 @@ PY
|
|||||||
-e EXPECTED_ENV="$expected_env" \
|
-e EXPECTED_ENV="$expected_env" \
|
||||||
-e EXPECTED_KEY_ID="$expected_key_id" \
|
-e EXPECTED_KEY_ID="$expected_key_id" \
|
||||||
-e EXPECT_RSA="$expect_rsa" \
|
-e EXPECT_RSA="$expect_rsa" \
|
||||||
-e EXPECTED_PUBLIC_URL="$expected_public_url" \
|
|
||||||
backend python -c "$check_code"
|
backend python -c "$check_code"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,7 +508,7 @@ run_health_checks() {
|
|||||||
[[ "$runtime_env" == "production" ]] && expect_rsa=1
|
[[ "$runtime_env" == "production" ]] && expect_rsa=1
|
||||||
check_container_status
|
check_container_status
|
||||||
check_dev_nginx_mode
|
check_dev_nginx_mode
|
||||||
check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa" "$BASE_URL"
|
check_backend_environment "$runtime_env" "$login_key_id" "$expect_rsa"
|
||||||
step "探测 HTTP 接口可用性"
|
step "探测 HTTP 接口可用性"
|
||||||
check_http_endpoint "/health"
|
check_http_endpoint "/health"
|
||||||
check_http_endpoint "/readyz"
|
check_http_endpoint "/readyz"
|
||||||
@@ -588,13 +555,10 @@ main() {
|
|||||||
confirm_install "$EFFECTIVE_PROJECT_NAME" "$EFFECTIVE_RUNTIME_ENV"
|
confirm_install "$EFFECTIVE_PROJECT_NAME" "$EFFECTIVE_RUNTIME_ENV"
|
||||||
prepare_env_file "$project_name" "$runtime_env" "$login_key_id"
|
prepare_env_file "$project_name" "$runtime_env" "$login_key_id"
|
||||||
sync_frontend_build_env "$runtime_env"
|
sync_frontend_build_env "$runtime_env"
|
||||||
# 对外访问基址同时用于健康检查和后端校验 ONLYOFFICE 返回的公开缓存 URL。
|
# 健康检查地址持久化到 .env(单一事实来源);独立 upsert,绕开 prepare_env_file 对已存在 .env 的跳过。
|
||||||
# 两项必须同步;否则生产域名下的最终保存回调会被当作不受信任地址拒绝。
|
|
||||||
ctms_upsert_env_value CTMS_BASE_URL "$BASE_URL"
|
ctms_upsert_env_value CTMS_BASE_URL "$BASE_URL"
|
||||||
ctms_upsert_env_value FRONTEND_PUBLIC_URL "$BASE_URL"
|
|
||||||
run_compose_config
|
run_compose_config
|
||||||
run_backend_init
|
run_backend_init
|
||||||
check_onlyoffice_active_sessions
|
|
||||||
run_build_and_start
|
run_build_and_start
|
||||||
run_migrations
|
run_migrations
|
||||||
resolve_effective_config "$project_name" "$runtime_env" "$login_key_id"
|
resolve_effective_config "$project_name" "$runtime_env" "$login_key_id"
|
||||||
|
|||||||
Reference in New Issue
Block a user