This commit is contained in:
zlwu committed 2026-08-13 16:50:36 +08:00
1 parent 8c49021d38
commit 283b8cd1e3
33 files changed
+3904 -1423

No files matched your search

+13 -13
View File
@@ -3517,23 +3517,23 @@
"services_oa_engine_progress_person",
"services_oa_engine_py",
"services_oa_engine_rationale_105",
"services_oa_engine_rationale_1155",
"services_oa_engine_rationale_1172",
"services_oa_engine_rationale_1158",
"services_oa_engine_rationale_1175",
"services_oa_engine_rationale_122",
"services_oa_engine_rationale_1253",
"services_oa_engine_rationale_1422",
"services_oa_engine_rationale_1577",
"services_oa_engine_rationale_1674",
"services_oa_engine_rationale_1256",
"services_oa_engine_rationale_1425",
"services_oa_engine_rationale_1580",
"services_oa_engine_rationale_1677",
"services_oa_engine_rationale_178",
"services_oa_engine_rationale_203",
"services_oa_engine_rationale_2040",
"services_oa_engine_rationale_2043",
"services_oa_engine_rationale_216",
"services_oa_engine_rationale_2165",
"services_oa_engine_rationale_2364",
"services_oa_engine_rationale_2558",
"services_oa_engine_rationale_2674",
"services_oa_engine_rationale_2784",
"services_oa_engine_rationale_2866",
"services_oa_engine_rationale_2168",
"services_oa_engine_rationale_2367",
"services_oa_engine_rationale_2561",
"services_oa_engine_rationale_2677",
"services_oa_engine_rationale_2787",
"services_oa_engine_rationale_2869",
"services_oa_engine_rationale_287",
"services_oa_engine_rationale_330",
"services_oa_engine_rationale_419",
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,7 +1,7 @@
# Graph Report - app (2026-08-13)
## Corpus Check
- Large corpus: 250 files · ~303,609 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
- Large corpus: 250 files · ~303,618 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
## Summary
- 5240 nodes · 28677 edges · 46 communities detected
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -218,7 +218,7 @@
"/Users/wuzilong/work/00code/zkxw/backend/app/api/protocol_sample_collection.py": 1780457788.2093296,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/leave.py": 1779266168.8693042,
"/Users/wuzilong/work/00code/zkxw/backend/app/api/clinical_payment.py": 1781750841.074568,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1786600784.1269102,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1786600896.5795553,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_overtime_hours.py": 1786373766.5113294,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/workflow_callback.py": 1778727966.7277644,
"/Users/wuzilong/work/00code/zkxw/backend/app/services/qc_work_order_fee_summary.py": 1783329884.6125586,
+95 -41
View File
@@ -6,30 +6,45 @@
<view class="nav__back-ico" aria-hidden="true" />
</view>
<text class="nav__title">抄送我的</text>
<view
class="nav__action"
:class="{ 'is-done': manageMode }"
@click="toggleManage"
>
{{ manageMode ? '完成' : '管理' }}
</view>
</view>
<view class="toolbar">
<view class="search-box" :class="{ 'is-focus': searchFocused }">
<view class="search-ico" aria-hidden="true" />
<input
class="search-input"
:value="keyword"
confirm-type="search"
placeholder="搜索流程名称、申请人"
placeholder-class="search-ph"
@input="onSearchInput"
@confirm="onSearchConfirm"
@focus="searchFocused = true"
@blur="searchFocused = false"
/>
<view v-if="keyword" class="search-clear" @click="clearKeyword">
<text class="search-clear__x">×</text>
<view class="toolbar-row">
<view class="search-box" :class="{ 'is-focus': searchFocused }">
<view class="search-ico" aria-hidden="true" />
<input
class="search-input"
:value="keyword"
confirm-type="search"
placeholder="搜索流程名称、申请人"
placeholder-class="search-ph"
@input="onSearchInput"
@confirm="onSearchConfirm"
@focus="searchFocused = true"
@blur="searchFocused = false"
/>
<view v-if="keyword" class="search-clear" @click="clearKeyword">
<text class="search-clear__x">×</text>
</view>
</view>
<view
class="manage-ico-btn"
:class="{ 'is-done': manageMode }"
@click="toggleManage"
>
<view
v-if="!manageMode"
class="manage-ico manage-ico--more"
aria-hidden="true"
>
<view class="manage-ico__dot" />
<view class="manage-ico__dot" />
<view class="manage-ico__dot" />
</view>
<view
v-else
class="manage-ico manage-ico--check"
aria-hidden="true"
/>
</view>
</view>
<scroll-view scroll-x class="tabs" :show-scrollbar="false">
@@ -568,32 +583,21 @@
color: #0f291e;
}
.nav__action {
margin-left: auto;
padding: 0 16rpx;
height: 72rpx;
display: flex;
align-items: center;
font-size: 28rpx;
font-weight: 650;
color: #166534;
}
.nav__action.is-done {
color: #64748b;
}
.nav__action:active {
opacity: 0.72;
}
.toolbar {
background: #fff;
padding: 16rpx 24rpx 0;
box-sizing: border-box;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 16rpx;
}
.search-box {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 16rpx;
@@ -609,6 +613,56 @@
box-shadow 0.15s;
}
.manage-ico-btn {
flex-shrink: 0;
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
border: 1rpx solid #c6f0dc;
background: #ecfdf5;
color: #166534;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.manage-ico-btn.is-done {
border-color: #e2e8f0;
background: #f8fafc;
color: #64748b;
}
.manage-ico-btn:active {
opacity: 0.72;
}
.manage-ico--more {
width: 34rpx;
height: 10rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.manage-ico__dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.manage-ico--check {
width: 20rpx;
height: 12rpx;
border-left: 4rpx solid currentColor;
border-bottom: 4rpx solid currentColor;
transform: rotate(-45deg);
margin-top: -4rpx;
box-sizing: border-box;
}
.search-box.is-focus {
border-color: #cbd5e1;
background: #fff;
+95 -41
View File
@@ -6,30 +6,45 @@
<view class="nav__back-ico" aria-hidden="true" />
</view>
<text class="nav__title">草稿箱</text>
<view
class="nav__action"
:class="{ 'is-done': manageMode }"
@click="toggleManage"
>
{{ manageMode ? '完成' : '管理' }}
</view>
</view>
<view class="toolbar">
<view class="search-box" :class="{ 'is-focus': searchFocused }">
<view class="search-ico" aria-hidden="true" />
<input
class="search-input"
:value="keyword"
confirm-type="search"
placeholder="搜索流程名称"
placeholder-class="search-ph"
@input="onSearchInput"
@confirm="onSearchConfirm"
@focus="searchFocused = true"
@blur="searchFocused = false"
/>
<view v-if="keyword" class="search-clear" @click="clearKeyword">
<text class="search-clear__x">×</text>
<view class="toolbar-row">
<view class="search-box" :class="{ 'is-focus': searchFocused }">
<view class="search-ico" aria-hidden="true" />
<input
class="search-input"
:value="keyword"
confirm-type="search"
placeholder="搜索流程名称"
placeholder-class="search-ph"
@input="onSearchInput"
@confirm="onSearchConfirm"
@focus="searchFocused = true"
@blur="searchFocused = false"
/>
<view v-if="keyword" class="search-clear" @click="clearKeyword">
<text class="search-clear__x">×</text>
</view>
</view>
<view
class="manage-ico-btn"
:class="{ 'is-done': manageMode }"
@click="toggleManage"
>
<view
v-if="!manageMode"
class="manage-ico manage-ico--more"
aria-hidden="true"
>
<view class="manage-ico__dot" />
<view class="manage-ico__dot" />
<view class="manage-ico__dot" />
</view>
<view
v-else
class="manage-ico manage-ico--check"
aria-hidden="true"
/>
</view>
</view>
</view>
@@ -449,31 +464,20 @@
color: #0f291e;
}
.nav__action {
margin-left: auto;
padding: 0 16rpx;
height: 72rpx;
display: flex;
align-items: center;
font-size: 28rpx;
font-weight: 650;
color: #166534;
}
.nav__action.is-done {
color: #64748b;
}
.nav__action:active {
opacity: 0.72;
}
.toolbar {
background: #fff;
padding: 16rpx 24rpx;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 16rpx;
}
.search-box {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 16rpx;
@@ -489,6 +493,56 @@
box-shadow 0.15s;
}
.manage-ico-btn {
flex-shrink: 0;
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
border: 1rpx solid #c6f0dc;
background: #ecfdf5;
color: #166534;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.manage-ico-btn.is-done {
border-color: #e2e8f0;
background: #f8fafc;
color: #64748b;
}
.manage-ico-btn:active {
opacity: 0.72;
}
.manage-ico--more {
width: 34rpx;
height: 10rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.manage-ico__dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.manage-ico--check {
width: 20rpx;
height: 12rpx;
border-left: 4rpx solid currentColor;
border-bottom: 4rpx solid currentColor;
transform: rotate(-45deg);
margin-top: -4rpx;
box-sizing: border-box;
}
.search-box.is-focus {
border-color: #cbd5e1;
background: #fff;
+115 -43
View File
@@ -5,13 +5,34 @@
</template>
<script>
import { post } from '../../../utils/request.js'
import { MINIO_BASE_URL } from '../../../utils/config.js'
import { API_BASE_URL, MINIO_BASE_URL } from '../../../utils/config.js'
import { getToken } from '../../../utils/auth.js'
import {
buildDirectMinioUrl,
parsePdfPreviewQuery,
pdfPreviewLog,
toAbsoluteDownloadUrl
} from '../../../utils/oa-pdf-preview-query.js'
function authHeader() {
const token = getToken()
return token ? { Authorization: 'Bearer ' + token } : {}
function absolutize(url) {
const abs = toAbsoluteDownloadUrl(url, {
apiBaseUrl: API_BASE_URL,
minioBaseUrl: MINIO_BASE_URL
})
if (String(url || '').trim() && abs && abs !== url) {
pdfPreviewLog('absolutize', {
from: String(url).slice(0, 120),
to: abs.slice(0, 160)
})
}
return abs
}
function directMinio(fileUrl) {
return buildDirectMinioUrl(fileUrl, {
apiBaseUrl: API_BASE_URL,
minioBaseUrl: MINIO_BASE_URL
})
}
export default {
@@ -22,62 +43,105 @@ export default {
}
},
onLoad(query) {
pdfPreviewLog('onLoad raw query', query)
this.openFromQuery(query || {})
},
methods: {
async openFromQuery(query) {
const name = decodeURIComponent(
String((query && query.name) || 'approval.pdf').trim()
)
const legacyUrl = decodeURIComponent(
String((query && query.url) || '').trim()
)
const fileUrl = decodeURIComponent(
String((query && query.fileUrl) || '').trim()
)
const parsed = parsePdfPreviewQuery(query)
const { fileUrl, legacyUrl, name, rawKeys } = parsed
pdfPreviewLog('parsed query', {
rawKeys,
name,
fileUrlLen: fileUrl.length,
fileUrlPreview: fileUrl.slice(0, 160),
legacyUrlLen: legacyUrl.length,
legacyUrlPreview: legacyUrl.slice(0, 120),
hasToken: !!getToken(),
minioBase: String(MINIO_BASE_URL || '').slice(0, 80)
})
// 微信 downloadFile:优先 MinIO 公网直链。
// download-url 预签名经 /minio 相对路径改写后签名失效,真机常 403。
let url = ''
if (legacyUrl && /^https?:\/\//i.test(legacyUrl)) {
url = legacyUrl
let fallbackUrl = ''
let source = ''
if (legacyUrl) {
const absLegacy = absolutize(legacyUrl)
const direct = directMinio(fileUrl || legacyUrl)
// legacy 若带坏签名,仍准备直链兜底
if (direct && /^https?:\/\//i.test(direct)) {
url = direct
fallbackUrl =
absLegacy && absLegacy !== direct ? absLegacy : ''
source = 'minio-direct(+legacy-fallback)'
} else {
url = absLegacy
source = 'query.url'
}
} else if (fileUrl) {
url = await this.resolveDownloadUrl(fileUrl, name)
url = directMinio(fileUrl)
source = 'minio-direct'
pdfPreviewLog('minio-direct', {
urlPreview: String(url || '').slice(0, 160)
})
}
url = absolutize(url) || url
fallbackUrl = absolutize(fallbackUrl) || fallbackUrl
if (!url || !/^https?:\/\//i.test(url)) {
const reason = !fileUrl && !legacyUrl
? '缺少 fileUrl/url 参数(请确认小程序与 oa-h5 已同步部署)'
: '无法解析为 http(s) 下载地址'
pdfPreviewLog('invalid url', { reason, fileUrl, legacyUrl, url })
this.tip = '文件地址无效'
uni.showToast({ title: '文件地址无效', icon: 'none' })
return
}
this.openPdf(url, name)
pdfPreviewLog('openPdf', {
source,
name,
urlPreview: url.slice(0, 160),
hasFallback: !!fallbackUrl
})
this.openPdf(url, name, fallbackUrl)
},
async resolveDownloadUrl(fileUrl, fileName) {
try {
const res = await post(
'/web/admin/attachment/download-url',
{
fileUrl,
fileName
},
authHeader()
)
const u = String(res?.downloadUrl || '').trim()
if (u) return u
} catch (_) {
/* fall through to MinIO base URL */
}
const base = String(MINIO_BASE_URL || '').replace(/\/$/, '')
const key = String(fileUrl || '').replace(/^\//, '')
return base && key ? base + '/' + key : ''
},
openPdf(url, name) {
openPdf(url, name, fallbackUrl) {
if (this._opened) return
this._opened = true
this._tryDownload(url, name, fallbackUrl, false)
},
_tryDownload(url, name, fallbackUrl, isRetry) {
pdfPreviewLog('downloadFile start', {
name,
isRetry: !!isRetry,
urlPreview: String(url || '').slice(0, 160)
})
uni.downloadFile({
url,
success: (res) => {
if (!res || res.statusCode !== 200 || !res.tempFilePath) {
this.tip = '下载失败'
uni.showToast({ title: '下载 PDF 失败', icon: 'none' })
const code = res && res.statusCode
pdfPreviewLog('downloadFile success', {
statusCode: code,
hasTemp: !!(res && res.tempFilePath),
isRetry: !!isRetry
})
if (code === 403 && fallbackUrl && !isRetry) {
pdfPreviewLog('403 → retry fallback', {
fallbackPreview: String(fallbackUrl).slice(0, 160)
})
this._tryDownload(fallbackUrl, name, '', true)
return
}
if (!res || code !== 200 || !res.tempFilePath) {
this.tip = '下载失败' + (code ? '(' + code + ')' : '')
uni.showToast({
title: '下载 PDF 失败' + (code ? ' ' + code : ''),
icon: 'none'
})
this._opened = false
return
}
@@ -86,19 +150,27 @@ export default {
fileType: 'pdf',
showMenu: true,
success: () => {
pdfPreviewLog('openDocument success')
this.tip = '已打开预览'
setTimeout(() => {
uni.navigateBack({ fail: () => {} })
}, 300)
},
fail: () => {
fail: (err) => {
pdfPreviewLog('openDocument fail', err)
this.tip = '无法打开文档'
uni.showToast({ title: '无法打开 PDF', icon: 'none' })
this._opened = false
}
})
},
fail: () => {
fail: (err) => {
pdfPreviewLog('downloadFile fail', err)
if (fallbackUrl && !isRetry) {
pdfPreviewLog('fail → retry fallback')
this._tryDownload(fallbackUrl, name, '', true)
return
}
this.tip = '下载失败'
uni.showToast({ title: '下载 PDF 失败', icon: 'none' })
this._opened = false
+95 -41
View File
@@ -6,30 +6,45 @@
<view class="nav__back-ico" aria-hidden="true" />
</view>
<text class="nav__title">待办中心</text>
<view
class="nav__action"
:class="{ 'is-done': manageMode }"
@click="toggleManage"
>
{{ manageMode ? '完成' : '管理' }}
</view>
</view>
<view class="toolbar">
<view class="search-box" :class="{ 'is-focus': searchFocused }">
<view class="search-ico" aria-hidden="true" />
<input
class="search-input"
:value="keyword"
confirm-type="search"
placeholder="搜索流程名称、申请人"
placeholder-class="search-ph"
@input="onSearchInput"
@confirm="onSearchConfirm"
@focus="searchFocused = true"
@blur="searchFocused = false"
/>
<view v-if="keyword" class="search-clear" @click="clearKeyword">
<text class="search-clear__x">×</text>
<view class="toolbar-row">
<view class="search-box" :class="{ 'is-focus': searchFocused }">
<view class="search-ico" aria-hidden="true" />
<input
class="search-input"
:value="keyword"
confirm-type="search"
placeholder="搜索流程名称、申请人"
placeholder-class="search-ph"
@input="onSearchInput"
@confirm="onSearchConfirm"
@focus="searchFocused = true"
@blur="searchFocused = false"
/>
<view v-if="keyword" class="search-clear" @click="clearKeyword">
<text class="search-clear__x">×</text>
</view>
</view>
<view
class="manage-ico-btn"
:class="{ 'is-done': manageMode }"
@click="toggleManage"
>
<view
v-if="!manageMode"
class="manage-ico manage-ico--more"
aria-hidden="true"
>
<view class="manage-ico__dot" />
<view class="manage-ico__dot" />
<view class="manage-ico__dot" />
</view>
<view
v-else
class="manage-ico manage-ico--check"
aria-hidden="true"
/>
</view>
</view>
</view>
@@ -565,31 +580,20 @@
color: #0f291e;
}
.nav__action {
margin-left: auto;
padding: 0 16rpx;
height: 72rpx;
display: flex;
align-items: center;
font-size: 28rpx;
font-weight: 650;
color: #166534;
}
.nav__action.is-done {
color: #64748b;
}
.nav__action:active {
opacity: 0.72;
}
.toolbar {
background: #fff;
padding: 16rpx 24rpx;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 16rpx;
}
.search-box {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 16rpx;
@@ -605,6 +609,56 @@
box-shadow 0.15s;
}
.manage-ico-btn {
flex-shrink: 0;
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
border: 1rpx solid #c6f0dc;
background: #ecfdf5;
color: #166534;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.manage-ico-btn.is-done {
border-color: #e2e8f0;
background: #f8fafc;
color: #64748b;
}
.manage-ico-btn:active {
opacity: 0.72;
}
.manage-ico--more {
width: 34rpx;
height: 10rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.manage-ico__dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.manage-ico--check {
width: 20rpx;
height: 12rpx;
border-left: 4rpx solid currentColor;
border-bottom: 4rpx solid currentColor;
transform: rotate(-45deg);
margin-top: -4rpx;
box-sizing: border-box;
}
.search-box.is-focus {
border-color: #cbd5e1;
background: #fff;
+8 -3
View File
@@ -6,6 +6,7 @@
<script>
import {
buildOaStartUrl,
resolveOaReturnPath,
takeOaStartTitle
} from '../../../utils/oa-h5.js'
import { navigateBackFromOaHost } from '../../../utils/oa-navigate-back.js'
@@ -39,13 +40,14 @@ export default {
this.resolveTitle(appId)
}
const src = buildOaStartUrl({ appId, draftId })
const returnPath = resolveOaReturnPath(query)
const src = buildOaStartUrl({ appId, draftId, returnPath })
if (!/\btoken=/.test(src)) {
forceRelogin('请先登录')
return
}
// 对齐水槽:onLoad 内同步赋值,与 pages/oa/form-sink/index 一致
console.log('[oa-start webview] src=', src)
console.log('[oa-start webview] src=', src, 'returnPath=', returnPath)
this.src = src
// H5:OA 页在 iframe 内,靠 postMessage 通知本页 uni.navigateBack
@@ -54,12 +56,14 @@ export default {
this._onParentMessage = (e) => {
const d = e && e.data
if (!d || d.source !== 'zkxw-oa-h5') return
console.log('[oa-start webview] iframe message', d)
this.navigateBackFromOa(d)
}
window.addEventListener('message', this._onParentMessage)
// #endif
},
onUnload() {
console.log('[oa-start webview] onUnload')
// #ifdef H5
if (this._onParentMessage) {
window.removeEventListener('message', this._onParentMessage)
@@ -80,6 +84,7 @@ export default {
}
},
navigateBackFromOa(payload) {
console.log('[oa-start webview] navigateBackFromOa', payload)
if (this._closing) return
const action = String((payload && payload.action) || '')
if (action && !CLOSE_ACTIONS.has(action)) return
@@ -99,7 +104,7 @@ export default {
},
onMessage(e) {
/* 微信:多在后退/销毁时触发;列表页 onShow 可刷新。勿再 navigateBack */
console.log('[oa-start webview] message', e && e.detail)
console.log('[oa-start webview] @message', e && e.detail)
}
}
}
+59 -6
View File
@@ -7,6 +7,7 @@
import {
buildOaDetailUrl,
buildOaStartUrl,
resolveOaReturnPath,
takeOaDetailTitle,
takeOaStartTitle
} from '../../../utils/oa-h5.js'
@@ -43,6 +44,9 @@ export default {
const draftId = String((query && query.draftId) || '')
const fromInstanceId = String((query && query.fromInstanceId) || '')
// 传给 OA H5:真机 navigateBack 静默失败时 wx.reLaunch/redirectTo 用
const returnPath = resolveOaReturnPath(query)
let src = ''
if (instanceId) {
@@ -50,7 +54,7 @@ export default {
if (cachedName) {
uni.setNavigationBarTitle({ title: cachedName })
}
src = buildOaDetailUrl({ instanceId, taskId, mode, autoPrint })
src = buildOaDetailUrl({ instanceId, taskId, mode, autoPrint, returnPath })
} else if (draftId || appId || fromInstanceId) {
const cachedName = takeOaStartTitle()
if (cachedName) {
@@ -58,7 +62,7 @@ export default {
} else if (appId) {
this.resolveTitle(appId)
}
src = buildOaStartUrl({ appId, draftId, fromInstanceId })
src = buildOaStartUrl({ appId, draftId, fromInstanceId, returnPath })
} else {
uni.showToast({ title: '参数错误', icon: 'none' })
setTimeout(() => {
@@ -74,7 +78,25 @@ export default {
return
}
console.log('[oa webview] src=', src)
console.log('[oa webview] onLoad', {
returnPath,
mode,
instanceId,
taskId,
appId,
draftId,
fromInstanceId,
src
})
try {
const pages = getCurrentPages() || []
console.log(
'[oa webview] stack',
pages.map((p) => (p && p.route) || '')
)
} catch (e) {
console.log('[oa webview] stack err', e)
}
this.src = src
// H5:OA 页在 iframe 内,靠 postMessage 通知本页 uni.navigateBack
@@ -83,12 +105,17 @@ export default {
this._onParentMessage = (e) => {
const d = e && e.data
if (!d || d.source !== 'zkxw-oa-h5') return
console.log('[oa webview] iframe message', d)
this.navigateBackFromOa(d)
}
window.addEventListener('message', this._onParentMessage)
// #endif
},
onShow() {
console.log('[oa webview] onShow closing=', this._closing)
},
onUnload() {
console.log('[oa webview] onUnload')
// #ifdef H5
if (this._onParentMessage) {
window.removeEventListener('message', this._onParentMessage)
@@ -109,9 +136,18 @@ export default {
}
},
navigateBackFromOa(payload) {
console.log(
'[oa webview] navigateBackFromOa',
payload,
'closing=',
this._closing
)
if (this._closing) return
const action = String((payload && payload.action) || '')
if (action && !CLOSE_ACTIONS.has(action)) return
if (action && !CLOSE_ACTIONS.has(action)) {
console.log('[oa webview] ignore action', action)
return
}
// 复制重开:离开详情 webview 栈时,改开发起页(同页 redirect)
if (action === 'oa-detail-restart') {
@@ -155,8 +191,25 @@ export default {
}, msg ? 400 : 0)
},
onMessage(e) {
/* 微信:多在后退/销毁时触发;列表页 onShow 可刷新。勿再 navigateBack */
console.log('[oa webview] message', e && e.detail)
// 微信:postMessage 多在 web-view 后退/销毁时送达。
// 若 H5 侧 navigateBack 已成功,此处再退会多退一层,故默认只打日志。
// 若携带需宿主处理的 action(如复制重开),在仍停留本页时兜底处理。
console.log('[oa webview] @message raw', e && e.detail)
const batches = (e && e.detail && e.detail.data) || []
const items = Array.isArray(batches) ? batches : [batches]
for (let i = items.length - 1; i >= 0; i--) {
const raw = items[i]
const payload =
raw && typeof raw === 'object' && raw.data !== undefined
? raw.data
: raw
const action = String((payload && payload.action) || '')
console.log('[oa webview] @message item', i, action, payload)
if (action === 'oa-detail-restart') {
this.navigateBackFromOa(payload)
return
}
}
}
}
}
@@ -4,6 +4,12 @@
padding: 24rpx 24rpx 0;
}
/* 空态虚线软卡:与列表卡同左右 gutter,避免贴边 */
.oa-empty-wrap {
padding: 40rpx 24rpx;
box-sizing: border-box;
}
.oa-task-list__empty,
.oa-task-list__end {
padding: 48rpx 16rpx 56rpx;
+1 -1
View File
@@ -4,7 +4,7 @@
*
* 微信公众平台须配置 request / 业务域名:www.chengyantek.cn(HTTPS,安全组放行 443)
*/
const USE_REMOTE_TEST = false
const USE_REMOTE_TEST = true
/** 本机联调 */
const LOCAL_API_BASE_URL = 'http://127.0.0.1:8000/api'
+30 -2
View File
@@ -28,25 +28,53 @@ export function buildFormSinkUrl() {
return withQuery('/oa-form-sink', token ? { token } : {})
}
/**
* 关闭 web-view 失败时的小程序回退路径(须以 /pages/ 开头)。
* 优先 query.returnPath;否则取页面栈上一页。
*/
export function resolveOaReturnPath(query) {
const fromQuery = String((query && query.returnPath) || '').trim()
if (fromQuery.indexOf('/pages/') === 0) return fromQuery
try {
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
if (pages && pages.length >= 2) {
const prev = pages[pages.length - 2]
const route = prev && prev.route
if (route) return '/' + String(route).replace(/^\//, '')
}
} catch (_) {
/* ignore */
}
return '/pages/index/index'
}
/** 正式发起页 URL(与水槽同一套拼法,仅多 appId/draftId/fromInstanceId) */
export function buildOaStartUrl({ appId, draftId, fromInstanceId } = {}) {
export function buildOaStartUrl({ appId, draftId, fromInstanceId, returnPath } = {}) {
const token = getToken()
const params = {}
if (appId) params.appId = appId
if (draftId) params.draftId = draftId
if (fromInstanceId) params.fromInstanceId = fromInstanceId
if (returnPath) params.returnPath = returnPath
if (token) params.token = token
return withQuery('/oa-start', params)
}
/** 审批详情页 URL(须带 token;mode 为 view | approve;autoPrint=1 加载后自动调打印) */
export function buildOaDetailUrl({ instanceId, taskId, mode, autoPrint } = {}) {
export function buildOaDetailUrl({
instanceId,
taskId,
mode,
autoPrint,
returnPath
} = {}) {
const token = getToken()
const params = {}
if (instanceId) params.instanceId = instanceId
if (taskId) params.taskId = taskId
if (mode) params.mode = mode // view | approve
if (autoPrint) params.autoPrint = '1'
if (returnPath) params.returnPath = returnPath
if (token) params.token = token
return withQuery('/oa-detail', params)
}
+149
View File
@@ -0,0 +1,149 @@
/**
* OA PDF 预览页 query 解析(短 fileUrl / 兼容旧 url)
* 微信 onLoad 可能已 decode 一次;须安全二次解码,并兼容大小写 key。
*/
const LOG_TAG = '[oa-pdf-preview]'
export function pdfPreviewLog(...args) {
try {
console.log(LOG_TAG, ...args)
} catch (_) {
/* ignore */
}
}
export function safeDecodeURIComponent(value) {
const raw = String(value == null ? '' : value).trim()
if (!raw) return ''
try {
return decodeURIComponent(raw)
} catch (_) {
return raw
}
}
/** 按候选 key 取值;忽略大小写;兼容微信偶发改写 */
export function pickQueryValue(query, keys) {
const q = query && typeof query === 'object' ? query : {}
const list = Array.isArray(keys) ? keys : [keys]
for (let i = 0; i < list.length; i++) {
const k = list[i]
if (k == null) continue
if (Object.prototype.hasOwnProperty.call(q, k) && q[k] != null) {
const v = String(q[k]).trim()
if (v) return v
}
}
const lowerMap = {}
Object.keys(q).forEach((k) => {
lowerMap[String(k).toLowerCase()] = q[k]
})
for (let i = 0; i < list.length; i++) {
const k = String(list[i] || '').toLowerCase()
if (!k) continue
if (lowerMap[k] != null) {
const v = String(lowerMap[k]).trim()
if (v) return v
}
}
return ''
}
/**
* @returns {{ fileUrl: string, legacyUrl: string, name: string, rawKeys: string[] }}
*/
export function parsePdfPreviewQuery(query) {
const rawKeys = Object.keys(query && typeof query === 'object' ? query : {})
const fileUrl = safeDecodeURIComponent(
pickQueryValue(query, ['fileUrl', 'fileurl', 'file_url', 'f'])
)
const legacyUrl = safeDecodeURIComponent(
pickQueryValue(query, ['url', 'downloadUrl', 'download_url'])
)
const name =
safeDecodeURIComponent(pickQueryValue(query, ['name', 'fileName', 'file_name', 'n'])) ||
'approval.pdf'
return { fileUrl, legacyUrl, name, rawKeys }
}
/**
* 后端 MINIO_BASE_URL 常配成同域相对路径(如 /minio/zkxw),预签名改写后也是
* `/minio/zkxw/...?X-Amz-...`。微信 downloadFile 必须绝对 http(s)。
*
* @param {string} url
* @param {{ apiBaseUrl?: string, minioBaseUrl?: string }} [bases]
*/
export function toAbsoluteDownloadUrl(url, bases = {}) {
const s = String(url || '').trim()
if (!s) return ''
if (/^https?:\/\//i.test(s)) return s
if (s.startsWith('//')) return 'https:' + s
const apiBase = String(bases.apiBaseUrl || '').trim()
const minioBase = String(bases.minioBaseUrl || '').trim()
const originFrom = (base) => {
const m = String(base || '').match(/^(https?:\/\/[^/]+)/i)
return m ? m[1] : ''
}
const origin = originFrom(apiBase) || originFrom(minioBase)
if (s.startsWith('/')) {
return origin ? origin + s : ''
}
// 相对 object key
const minio = minioBase.replace(/\/$/, '')
if (/^https?:\/\//i.test(minio)) {
return minio + '/' + s.replace(/^\//, '')
}
if (minio.startsWith('/') && origin) {
return origin + minio + '/' + s.replace(/^\//, '')
}
return ''
}
/**
* 拼 MinIO 公网直链(无预签名)。
* 预签名经相对路径改写后微信 downloadFile 常 403;公网直链对本环境可 200。
*/
export function buildDirectMinioUrl(fileUrl, bases = {}) {
const raw = String(fileUrl || '').trim()
if (!raw) return ''
const minioBase = String(bases.minioBaseUrl || '').replace(/\/$/, '')
const apiBase = String(bases.apiBaseUrl || '').trim()
const originMatch = (apiBase || minioBase).match(/^(https?:\/\/[^/]+)/i)
const origin = originMatch ? originMatch[1] : ''
// 已是绝对 http(s):去掉 query(签名)后若可识别为同域 MinIO 则规范化,否则原样去 query
if (/^https?:\/\//i.test(raw)) {
const noQuery = raw.split('?')[0]
try {
const u = new URL(noQuery)
const minioPath = minioBase.replace(/^https?:\/\/[^/]+/i, '') || ''
if (minioPath && u.pathname.indexOf(minioPath) === 0) {
return origin && minioPath.startsWith('/')
? origin + u.pathname
: noQuery
}
} catch (_) {
/* ignore */
}
return noQuery
}
// 根相对:/minio/zkxw/key.pdf?... → 拼 origin
if (raw.startsWith('/')) {
const pathOnly = raw.split('?')[0]
return origin ? origin + pathOnly : toAbsoluteDownloadUrl(pathOnly, bases)
}
// object key:去掉可能误带的 minio 路径前缀
let key = raw.replace(/^\//, '').split('?')[0]
const minioPath = minioBase.replace(/^https?:\/\/[^/]+/i, '').replace(/^\//, '')
if (minioPath && key.indexOf(minioPath + '/') === 0) {
key = key.slice(minioPath.length + 1)
}
return toAbsoluteDownloadUrl(key, bases)
}
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict'
import {
buildDirectMinioUrl,
parsePdfPreviewQuery,
pickQueryValue,
safeDecodeURIComponent,
toAbsoluteDownloadUrl
} from './oa-pdf-preview-query.js'
assert.equal(safeDecodeURIComponent('a%2Fb.pdf'), 'a/b.pdf')
assert.equal(safeDecodeURIComponent('100%完成.pdf'), '100%完成.pdf')
assert.equal(
pickQueryValue({ fileUrl: 'k1', f: 'k2' }, ['fileUrl', 'f']),
'k1'
)
assert.equal(pickQueryValue({ FILEURL: 'k-upper' }, ['fileUrl']), 'k-upper')
assert.equal(pickQueryValue({ f: 'short' }, ['fileUrl', 'f']), 'short')
const parsed = parsePdfPreviewQuery({
fileUrl: encodeURIComponent('oa-approval/x.pdf'),
name: 'approval.pdf'
})
assert.equal(parsed.fileUrl, 'oa-approval/x.pdf')
assert.equal(parsed.name, 'approval.pdf')
const fromF = parsePdfPreviewQuery({ f: 'oa-approval/y.pdf' })
assert.equal(fromF.fileUrl, 'oa-approval/y.pdf')
const legacy = parsePdfPreviewQuery({
url: 'https://example.com/a.pdf'
})
assert.equal(legacy.legacyUrl, 'https://example.com/a.pdf')
assert.equal(legacy.fileUrl, '')
const bases = {
apiBaseUrl: 'https://www.chengyantek.cn/api',
minioBaseUrl: 'https://www.chengyantek.cn/minio/zkxw'
}
assert.equal(
toAbsoluteDownloadUrl(
'/minio/zkxw/oa-approval/x.pdf?X-Amz-Signature=abc',
bases
),
'https://www.chengyantek.cn/minio/zkxw/oa-approval/x.pdf?X-Amz-Signature=abc'
)
assert.equal(
toAbsoluteDownloadUrl('https://cdn.example/a.pdf', bases),
'https://cdn.example/a.pdf'
)
assert.equal(
toAbsoluteDownloadUrl('oa-approval/x.pdf', bases),
'https://www.chengyantek.cn/minio/zkxw/oa-approval/x.pdf'
)
assert.equal(
toAbsoluteDownloadUrl('//cdn.example/a.pdf', bases),
'https://cdn.example/a.pdf'
)
assert.equal(
buildDirectMinioUrl('oa-approval/x.pdf', bases),
'https://www.chengyantek.cn/minio/zkxw/oa-approval/x.pdf'
)
assert.equal(
buildDirectMinioUrl(
'/minio/zkxw/oa-approval/x.pdf?X-Amz-Signature=broken',
bases
),
'https://www.chengyantek.cn/minio/zkxw/oa-approval/x.pdf'
)
console.log('oa-pdf-preview-query: ok')
+2
View File
@@ -7,6 +7,8 @@
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title>中科兴皖 · OA 表单</title>
<!-- 微信小程序 web-view 内返回宿主页:须 JSSDK ≥ 1.3.2(wx.miniProgram.navigateBack) -->
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
</head>
<body>
<div id="app"></div>
+344 -18
View File
@@ -1,60 +1,386 @@
/**
* 通知宿主(小程序 web-view / uni-app H5 iframe)关闭并返回上一页。
*
* 微信真机常见:toast 成功但 wx.miniProgram.navigateBack 静默失败(不走 fail)。
* 对齐 sqxp-wx 习惯:reLaunch,失败再用 redirectTo;并加超时兜底。
*
* 注意:Vite ESM 中裸标识符 `wx`/`uni` 往往不可见,必须读 window.wx / window.uni。
*
* @param {object} payload
* @param {{ wx?: any, uni?: any, window?: any, showToast?: (msg: string) => void, fallbackMessage?: string }} [deps]
* @param {{
* wx?: any,
* uni?: any,
* window?: any,
* showToast?: (msg: string) => void,
* fallbackMessage?: string,
* fallbackMiniPath?: string,
* schedule?: (fn: () => void, ms?: number) => void,
* silentFailMs?: number,
* toastDelayMs?: number,
* log?: (...args: any[]) => void
* }} [deps]
* @returns {'wx' | 'iframe' | 'uni' | 'toast' | 'none'}
*/
export function closeToHost(payload, deps = {}) {
const data = payload || { action: 'oa-close' }
const wxApi = deps.wx !== undefined ? deps.wx : typeof wx !== 'undefined' ? wx : undefined
const uniApi = deps.uni !== undefined ? deps.uni : typeof uni !== 'undefined' ? uni : undefined
const win = deps.window !== undefined ? deps.window : typeof window !== 'undefined' ? window : undefined
const win =
deps.window !== undefined
? deps.window
: typeof window !== 'undefined'
? window
: undefined
const log =
deps.log ||
((...args) => {
try {
console.log('[oa-close]', ...args)
} catch (_) {
/* ignore */
}
})
const wxApi =
deps.wx !== undefined
? deps.wx
: resolveGlobalApi(win, 'wx')
const uniApi =
deps.uni !== undefined
? deps.uni
: resolveGlobalApi(win, 'uni')
const toast =
deps.showToast ||
((msg) => {
/* no-op without toast */
void msg
})
const fallbackMessage = deps.fallbackMessage || '已完成(非小程序环境)'
const fallbackMiniPath =
String(deps.fallbackMiniPath || '/pages/index/index').trim() ||
'/pages/index/index'
const schedule =
deps.schedule ||
((fn, ms) => {
setTimeout(fn, ms || 0)
})
const silentFailMs =
typeof deps.silentFailMs === 'number' && deps.silentFailMs >= 0
? deps.silentFailMs
: 480
const toastDelayMs =
typeof deps.toastDelayMs === 'number' && deps.toastDelayMs >= 0
? deps.toastDelayMs
: 320
try {
if (wxApi?.miniProgram?.navigateBack) {
// 小程序宿主不展示 payload.message,须在 web-view 内提示
if (data && data.message) toast(String(data.message))
wxApi.miniProgram.postMessage?.({ data })
wxApi.miniProgram.navigateBack()
return 'wx'
const env = describeEnv(win, wxApi, uniApi)
log('start', {
action: data && data.action,
message: data && data.message,
fallbackMiniPath,
toastDelayMs,
silentFailMs,
env
})
const inMiniProgram = isLikelyMiniProgram(win, wxApi)
const mp = wxApi && wxApi.miniProgram
// 微信小程序 web-view:须 jweixin;navigateBack 失败/静默失败时用 reLaunch/redirectTo
if (inMiniProgram && mp && typeof mp.navigateBack === 'function') {
log('branch=wx', {
hasPostMessage: typeof mp.postMessage === 'function',
hasReLaunch: typeof mp.reLaunch === 'function',
hasRedirectTo: typeof mp.redirectTo === 'function',
hasNavigateTo: typeof mp.navigateTo === 'function'
})
if (data && data.message) toast(String(data.message))
try {
if (typeof mp.postMessage === 'function') {
mp.postMessage({ data })
log('postMessage ok', data)
} else {
log('postMessage missing')
}
} catch (e) {
log('postMessage throw', String(e && e.message ? e.message : e))
}
} catch (_) {
/* ignore */
// 额外打 getEnv,确认是否真在小程序 web-view
try {
if (typeof mp.getEnv === 'function') {
mp.getEnv((res) => {
log('getEnv', res)
})
}
} catch (e) {
log('getEnv throw', String(e && e.message ? e.message : e))
}
let settled = false
const markSettled = (tag, detail) => {
settled = true
log(tag, detail || {})
}
const failFallback = (reason) => {
if (settled) {
log('failFallback skip(already settled)', reason)
return
}
settled = true
log('failFallback', { reason, url: fallbackMiniPath })
try {
if (typeof mp.reLaunch === 'function') {
mp.reLaunch({
url: fallbackMiniPath,
success: (res) => log('reLaunch success', res),
fail: (err) => {
log('reLaunch fail', err)
try {
if (typeof mp.redirectTo === 'function') {
mp.redirectTo({
url: fallbackMiniPath,
success: (res) => log('redirectTo success', res),
fail: (e2) => log('redirectTo fail', e2)
})
} else {
log('redirectTo missing')
}
} catch (e) {
log('redirectTo throw', String(e && e.message ? e.message : e))
}
}
})
return
}
log('reLaunch missing, try redirectTo')
if (typeof mp.redirectTo === 'function') {
mp.redirectTo({
url: fallbackMiniPath,
success: (res) => log('redirectTo success', res),
fail: (err) => log('redirectTo fail', err)
})
}
} catch (e) {
log('failFallback throw', String(e && e.message ? e.message : e))
}
}
const doBack = () => {
log('doBack call navigateBack')
try {
mp.navigateBack({
delta: 1,
success: (res) => markSettled('navigateBack success', res),
fail: (err) => {
log('navigateBack fail', err)
failFallback('navigateBack.fail')
},
complete: (res) => log('navigateBack complete', res)
})
} catch (e) {
log('navigateBack throw', String(e && e.message ? e.message : e))
failFallback('navigateBack.throw')
return
}
// 真机常见:既不 success 也不 fail,仍停在 web-view
if (silentFailMs > 0) {
schedule(() => {
if (!settled) {
log('silent timeout → fallback', { silentFailMs })
failFallback('silent-timeout')
} else {
log('silent timeout skipped(settled)')
}
}, silentFailMs)
}
}
const startBack = () => {
log('startBack → wait bridge')
whenWxBridgeReady(win, schedule, doBack, log)
}
// toast 后略延时,避免部分真机 bridge / toast 抢占导致静默失败
schedule(startBack, data && data.message ? toastDelayMs : 0)
return 'wx'
}
log('skip wx branch', {
inMiniProgram,
hasMp: !!mp,
hasNavigateBack: !!(mp && typeof mp.navigateBack === 'function')
})
// H5 嵌入:优先 parent postMessage,避免 uni.webView.navigateBack 空操作后提前 return
// 成功文案由宿主 uni.showToast 展示,此处不再 toast
const inIframe = !!(win && win.parent && win.parent !== win)
if (inIframe) {
try {
win.parent.postMessage({ source: 'zkxw-oa-h5', ...data }, '*')
log('branch=iframe postMessage')
return 'iframe'
} catch (_) {
/* fall through */
} catch (e) {
log('iframe postMessage throw', String(e && e.message ? e.message : e))
}
} else {
log('not iframe', {
hasWin: !!win,
hasParent: !!(win && win.parent),
parentIsSelf: !!(win && win.parent === win)
})
}
try {
const uniWv = uniApi?.webView || null
if (uniWv?.postMessage) {
uniWv.postMessage({ data })
log('uni.webView.postMessage')
}
if (uniWv?.navigateBack) {
if (data && data.message) toast(String(data.message))
uniWv.navigateBack()
log('branch=uni navigateBack')
return 'uni'
}
log('uni.webView navigateBack missing', { hasUniWv: !!uniWv })
} catch (e) {
log('uni path throw', String(e && e.message ? e.message : e))
}
toast((data && data.message) || fallbackMessage)
log('branch=toast only (no host navigation)')
return 'toast'
}
/** ESM 下读全局 API:优先 window.xxx,再尝试裸全局 */
function resolveGlobalApi(win, name) {
try {
if (win && win[name] != null) return win[name]
} catch (_) {
/* ignore */
}
try {
if (name === 'wx' && typeof wx !== 'undefined') return wx
} catch (_) {
/* ignore */
}
try {
if (name === 'uni' && typeof uni !== 'undefined') return uni
} catch (_) {
/* ignore */
}
return undefined
}
function describeEnv(win, wxApi, uniApi) {
let ua = ''
try {
ua =
(win && win.navigator && win.navigator.userAgent) ||
(typeof navigator !== 'undefined' ? navigator.userAgent : '') ||
''
} catch (_) {
ua = ''
}
let wxEnv = ''
try {
wxEnv = win && win.__wxjs_environment
} catch (_) {
wxEnv = ''
}
let hasBridge = false
try {
hasBridge = !!(
win &&
win.WeixinJSBridge &&
typeof win.WeixinJSBridge.invoke === 'function'
)
} catch (_) {
hasBridge = false
}
return {
ua: String(ua).slice(0, 160),
__wxjs_environment: wxEnv || '',
hasWindowWx: !!(win && win.wx),
hasWxApi: !!wxApi,
hasMiniProgram: !!(wxApi && wxApi.miniProgram),
hasNavigateBack: !!(
wxApi &&
wxApi.miniProgram &&
typeof wxApi.miniProgram.navigateBack === 'function'
),
hasUni: !!uniApi,
hasUniWebView: !!(uniApi && uniApi.webView),
hasWeixinJSBridge: hasBridge,
href: (() => {
try {
return String((win && win.location && win.location.href) || '').slice(
0,
200
)
} catch (_) {
return ''
}
})()
}
}
function isLikelyMiniProgram(win, wxApi) {
try {
if (win && win.__wxjs_environment === 'miniprogram') return true
} catch (_) {
/* ignore */
}
try {
const ua =
(win && win.navigator && win.navigator.userAgent) ||
(typeof navigator !== 'undefined' ? navigator.userAgent : '') ||
''
if (/miniProgram/i.test(String(ua))) return true
} catch (_) {
/* ignore */
}
// 兼容:部分真机未写 UA / __wxjs_environment,但已注入 wx.miniProgram
try {
return !!(wxApi && wxApi.miniProgram && wxApi.miniProgram.navigateBack)
} catch (_) {
return false
}
}
/** 等待 WeixinJSBridge;已就绪或超时则执行(只跑一次) */
function whenWxBridgeReady(win, schedule, fn, log) {
let done = false
const run = (reason) => {
if (done) return
done = true
if (log) log('bridge ready → doBack', reason)
fn()
}
try {
if (win && win.WeixinJSBridge && typeof win.WeixinJSBridge.invoke === 'function') {
run('already')
return
}
} catch (_) {
/* fall through */
}
const onReady = () => run('event')
try {
if (win && win.document && typeof win.document.addEventListener === 'function') {
win.document.addEventListener('WeixinJSBridgeReady', onReady, false)
}
} catch (_) {
/* ignore */
}
try {
if (win && typeof win.addEventListener === 'function') {
win.addEventListener('WeixinJSBridgeReady', onReady, false)
}
} catch (_) {
/* ignore */
}
toast((data && data.message) || fallbackMessage)
return 'toast'
// 不等太久:部分环境永不派发 Ready
schedule(() => run('timeout-280'), 280)
}
+167 -3
View File
@@ -10,12 +10,20 @@ import { closeToHost } from './close-to-host.js'
wx: {
miniProgram: {
postMessage: (p) => calls.push(['post', p]),
navigateBack: () => calls.push(['back'])
navigateBack: (opts) => {
calls.push(['back', opts])
if (opts && typeof opts.success === 'function') opts.success()
}
}
},
window: { parent: {} },
window: {
__wxjs_environment: 'miniprogram',
parent: {}
},
uni: { webView: { navigateBack: () => calls.push(['uni-back']) } },
showToast: (m) => calls.push(['toast', m])
showToast: (m) => calls.push(['toast', m]),
now: () => 0,
schedule: (fn) => fn()
}
)
assert.equal(result, 'wx')
@@ -24,6 +32,131 @@ import { closeToHost } from './close-to-host.js'
['toast', 'post', 'back']
)
assert.equal(calls[0][1], '提交成功')
assert.equal(calls[2][1].delta, 1)
}
// navigateBack fail → reLaunch 兜底(真机常见:toast 成功但不返回)
{
const calls = []
const result = closeToHost(
{ action: 'oa-detail-done', message: '已同意' },
{
wx: {
miniProgram: {
postMessage: () => calls.push('post'),
navigateBack: (opts) => {
calls.push('back')
if (opts && typeof opts.fail === 'function') {
opts.fail({ errMsg: 'navigateBack:fail' })
}
},
reLaunch: (opts) => {
calls.push(['reLaunch', opts && opts.url])
if (opts && typeof opts.success === 'function') opts.success()
}
}
},
window: {
__wxjs_environment: 'miniprogram',
parent: {}
},
showToast: (m) => calls.push(['toast', m]),
fallbackMiniPath: '/pages/oa/pending/index',
schedule: (fn) => fn()
}
)
assert.equal(result, 'wx')
assert.equal(calls[0][0], 'toast')
assert.equal(calls[1], 'post')
assert.equal(calls[2], 'back')
assert.deepEqual(calls[3], ['reLaunch', '/pages/oa/pending/index'])
}
// navigateBack 静默无回调 → 超时后 reLaunch(对齐 sqxp:reLaunch + redirectTo)
{
const calls = []
const deferred = []
const result = closeToHost(
{ action: 'oa-detail-done', message: '已同意' },
{
wx: {
miniProgram: {
postMessage: () => calls.push('post'),
navigateBack: () => calls.push('back'),
reLaunch: (opts) => {
calls.push(['reLaunch', opts && opts.url])
},
redirectTo: (opts) => {
calls.push(['redirectTo', opts && opts.url])
}
}
},
window: {
__wxjs_environment: 'miniprogram',
parent: {},
WeixinJSBridge: { invoke() {} }
},
showToast: (m) => calls.push(['toast', m]),
fallbackMiniPath: '/pages/oa/pending/index',
silentFailMs: 400,
schedule: (fn, ms) => {
deferred.push({ fn, ms: ms || 0 })
}
}
)
assert.equal(result, 'wx')
// toast 延时后才 navigateBack
assert.equal(calls[0][0], 'toast')
assert.equal(calls[1], 'post')
assert.equal(deferred.length >= 1, true)
deferred[0].fn()
assert.equal(calls[2], 'back')
// 静默失败:跑完超时任务应 reLaunch
const timeoutTask = deferred.find((d) => d.ms >= 400)
assert.ok(timeoutTask, 'should schedule silent-fail timeout')
timeoutTask.fn()
assert.deepEqual(calls[3], ['reLaunch', '/pages/oa/pending/index'])
}
// navigateBack success → 取消超时,不再 reLaunch
{
const calls = []
const deferred = []
closeToHost(
{ action: 'oa-detail-done', message: '已打回' },
{
wx: {
miniProgram: {
postMessage: () => calls.push('post'),
navigateBack: (opts) => {
calls.push('back')
if (opts && typeof opts.success === 'function') opts.success()
},
reLaunch: (opts) => calls.push(['reLaunch', opts && opts.url])
}
},
window: {
__wxjs_environment: 'miniprogram',
parent: {},
WeixinJSBridge: { invoke() {} }
},
showToast: () => {},
fallbackMiniPath: '/pages/oa/pending/index',
silentFailMs: 400,
schedule: (fn, ms) => {
deferred.push({ fn, ms: ms || 0 })
}
}
)
deferred[0].fn()
assert.equal(calls.includes('back'), true)
const timeoutTask = deferred.find((d) => d.ms >= 400)
assert.ok(timeoutTask)
timeoutTask.fn()
assert.equal(
calls.some((c) => Array.isArray(c) && c[0] === 'reLaunch'),
false
)
}
// H5 iframe:即使存在 uni.webView.navigateBack,也必须走 parent postMessage(修复空操作提前 return)
@@ -101,4 +234,35 @@ import { closeToHost } from './close-to-host.js'
assert.deepEqual(calls, ['提交成功'])
}
// ESM 场景:只能从 window.wx 拿到 API(裸 wx 不可见)
{
const calls = []
const result = closeToHost(
{ action: 'oa-detail-done', message: '已同意' },
{
// 不传 deps.wx,强制走 window.wx
window: {
__wxjs_environment: 'miniprogram',
parent: {},
WeixinJSBridge: { invoke() {} },
wx: {
miniProgram: {
postMessage: () => calls.push('post'),
navigateBack: (opts) => {
calls.push('back')
if (opts && typeof opts.success === 'function') opts.success()
}
}
}
},
uni: undefined,
showToast: (m) => calls.push(['toast', m]),
schedule: (fn) => fn(),
log: () => {}
}
)
assert.equal(result, 'wx')
assert.equal(calls.includes('back'), true)
}
console.log('close-to-host.test.js: ok')
@@ -1,11 +1,13 @@
<template>
<div class="oa-pick">
<!-- 只读:勿塞进 van-field input(单行不换行会撑出右侧) -->
<div v-if="disabled" class="oa-pick__readonly">{{ displayText || '—' }}</div>
<van-field
v-else
:model-value="displayText"
readonly
is-link
input-align="left"
:disabled="disabled"
placeholder="请选择部门"
@click="openPicker"
/>
@@ -1,11 +1,13 @@
<template>
<div class="oa-pick">
<!-- 只读:勿塞进 van-field input(单行不换行会撑出右侧) -->
<div v-if="disabled" class="oa-pick__readonly">{{ displayText || '—' }}</div>
<van-field
v-else
:model-value="displayText"
readonly
is-link
input-align="left"
:disabled="disabled"
placeholder="请选择人员"
@click="openPicker"
/>
@@ -1,11 +1,13 @@
<template>
<div class="oa-pick">
<!-- 只读:勿塞进 van-field input(单行不换行会撑出右侧) -->
<div v-if="disabled" class="oa-pick__readonly">{{ displayText || '—' }}</div>
<van-field
v-else
:model-value="displayText"
readonly
is-link
input-align="left"
:disabled="disabled"
placeholder="请选择职位"
@click="openPicker"
/>
@@ -1,20 +1,24 @@
<template>
<div class="oa-recv">
<!--
可编辑:触发条仅作选择入口(摘要短文案);完整账户信息在下方可换行展示。
只读/详情:不走 van-field input(单行不换行会撑出右侧),直接按行展示。
-->
<van-field
:model-value="summary"
v-if="!disabled"
:model-value="pickerSummary"
readonly
:is-link="!disabled"
is-link
input-align="left"
:disabled="disabled"
placeholder="请选择收款账户"
@click="openPicker"
/>
<!-- 单账户已在 summary 展示完整行,避免详情/发起页重复两遍 -->
<div v-if="normalized.length > 1" class="oa-recv__detail">
<div v-if="normalized.length" class="oa-recv__detail" :class="{ 'is-readonly': disabled }">
<div v-for="item in normalized" :key="item.id" class="oa-recv__line">
{{ formatReceiveAccountLine(item) }}
</div>
</div>
<div v-else-if="disabled" class="oa-recv__empty">—</div>
<van-popup v-model:show="visible" position="bottom" round :style="{ height: '70%' }">
<div class="oa-recv__sheet">
@@ -86,10 +90,10 @@ const snapshotMap = ref(new Map())
const accountOptions = ref([])
const normalized = computed(() => normalizeReceiveAccountModelValue(props.modelValue))
const summary = computed(() => {
/** 触发条短文案;完整信息见下方 oa-recv__line(可换行) */
const pickerSummary = computed(() => {
const n = normalized.value.length
if (!n) return ''
if (n === 1) return formatReceiveAccountLine(normalized.value[0])
return `已选 ${n} 个账户`
})
const filtered = computed(() => {
@@ -177,13 +181,33 @@ onMounted(() => {
<style scoped>
.oa-recv__detail {
padding: 0 12px 8px;
padding: 0 0 8px;
color: var(--text-muted, #5f7a6d);
font-size: 12px;
line-height: 1.5;
text-align: left;
}
.oa-recv__detail.is-readonly {
padding: 2px 0 0;
color: var(--text-primary, #0f291e);
font-size: 13px;
line-height: 1.55;
}
.oa-recv__line {
word-break: break-all;
overflow-wrap: anywhere;
}
.oa-recv__line + .oa-recv__line {
margin-top: 4px;
margin-top: 6px;
}
.oa-recv__empty {
padding: 2px 0 0;
color: var(--text-muted, #5f7a6d);
font-size: 13px;
line-height: 1.5;
}
</style>
@@ -383,6 +383,8 @@ watch(visible, (v) => {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #0f291e);
word-break: break-all;
overflow-wrap: anywhere;
}
.oa-rel__actions {
display: flex;
@@ -420,6 +422,7 @@ watch(visible, (v) => {
display: flex;
gap: 8px;
margin-top: 4px;
min-width: 0;
color: var(--text-muted, #5f7a6d);
font-size: 12px;
}
@@ -427,6 +430,11 @@ watch(visible, (v) => {
flex: 0 0 auto;
color: #94a3b8;
}
.oa-rel__field > span:not(.oa-rel__label) {
min-width: 0;
word-break: break-all;
overflow-wrap: anywhere;
}
/* 方案 C · 分割线行;内容对齐 Web 选单卡片字段 */
.oa-rel__pick-row {
@@ -17,6 +17,11 @@ import { toRmbUpper } from '../../utils/rmb-upper.js'
import { resolveStamp } from '../oa-status-stamp.js'
import { buildOaPrintFilename } from './oa-print-filename.js'
import { buildFlowItems } from './oa-print-flow.js'
import {
isWeChatMiniWebView,
navigateToHostPdfPreview,
printNavLog
} from './oa-pdf-preview-nav.js'
export { resolveStamp }
@@ -667,54 +672,6 @@ export async function generateOaApprovalPdfBlob(detail, opts = {}) {
}
}
function isWeChatMiniWebView() {
try {
if (typeof window !== 'undefined' && window.__wxjs_environment === 'miniprogram') {
return true
}
} catch {
/* ignore */
}
try {
if (typeof wx !== 'undefined' && wx.miniProgram) return true
} catch {
/* ignore */
}
try {
const ua = String(navigator?.userAgent || '')
if (/miniProgram/i.test(ua)) return true
} catch {
/* ignore */
}
return false
}
function navigateToHostPdfPreview({ fileUrl, fileName }) {
const q =
'fileUrl=' +
encodeURIComponent(fileUrl) +
'&name=' +
encodeURIComponent(fileName || 'approval.pdf')
const path = '/pages/oa/pdf-preview/index?' + q
try {
if (typeof wx !== 'undefined' && wx.miniProgram?.navigateTo) {
wx.miniProgram.navigateTo({ url: path })
return true
}
} catch {
/* ignore */
}
try {
if (typeof uni !== 'undefined' && uni.webView?.navigateTo) {
uni.webView.navigateTo({ url: path })
return true
}
} catch {
/* ignore */
}
return false
}
/**
* @returns {Promise<{ ok: boolean, message?: string, mode?: 'preview' | 'download' }>}
*/
@@ -723,38 +680,59 @@ export async function exportOaApprovalPdf(detail, opts = {}) {
return { ok: false, message: '暂无审批详情,无法导出 PDF' }
}
const inMini = isWeChatMiniWebView()
printNavLog('export start', {
inMini,
instanceId: detail && (detail.id || detail.instanceId),
title: detail && (detail.title || detail.appName)
})
let blob
let filename
try {
;({ blob, filename } = await generateOaApprovalPdfBlob(detail, opts))
printNavLog('pdf blob ready', {
filename,
blobSize: blob && typeof blob.size === 'number' ? blob.size : -1
})
} catch (e) {
printNavLog('pdf generate fail', String(e && e.message ? e.message : e))
return {
ok: false,
message: (e && e.message) || '生成 PDF 失败'
}
}
if (isWeChatMiniWebView()) {
if (inMini) {
try {
const file = new File([blob], filename, { type: 'application/pdf' })
printNavLog('upload start', { filename, fileSize: file.size })
const res = await uploadAttachment(file)
const fileUrl = String(res?.fileUrl || res?.data?.fileUrl || '').trim()
printNavLog('upload done', {
hasFileUrl: !!fileUrl,
fileUrlPreview: fileUrl.slice(0, 160),
resKeys: res && typeof res === 'object' ? Object.keys(res) : []
})
if (!fileUrl) {
return { ok: false, message: '上传 PDF 失败:无文件地址' }
}
const navigated = navigateToHostPdfPreview({
const nav = await navigateToHostPdfPreview({
fileUrl,
fileName: filename
})
if (!navigated) {
printNavLog('navigate result', nav)
if (!nav || !nav.ok) {
return {
ok: false,
message: '无法跳转预览页,请升级微信后重试'
message:
(nav && nav.message) || '无法跳转预览页,请升级微信后重试'
}
}
return { ok: true, mode: 'preview' }
} catch (e) {
printNavLog('upload/navigate fail', String(e && e.message ? e.message : e))
return {
ok: false,
message: (e && e.message) || '上传或打开 PDF 失败'
@@ -763,6 +741,7 @@ export async function exportOaApprovalPdf(detail, opts = {}) {
}
try {
printNavLog('browser download path', { filename })
const href = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = href
@@ -773,6 +752,7 @@ export async function exportOaApprovalPdf(detail, opts = {}) {
setTimeout(() => URL.revokeObjectURL(href), 2000)
return { ok: true, mode: 'download' }
} catch (e) {
printNavLog('browser download fail', String(e && e.message ? e.message : e))
return {
ok: false,
message: (e && e.message) || '下载 PDF 失败'
@@ -0,0 +1,206 @@
/**
* 微信 web-view → 小程序 PDF 预览页跳转
* 注意:Vite ESM 须读 window.wx / window.uni,裸标识符常不可见(对齐 close-to-host)。
*/
const LOG_TAG = '[oa-print-nav]'
export function printNavLog(...args) {
try {
console.log(LOG_TAG, ...args)
} catch (_) {
/* ignore */
}
}
function resolveGlobalApi(win, name) {
try {
if (win && win[name] != null) return win[name]
} catch (_) {
/* ignore */
}
try {
if (name === 'wx' && typeof wx !== 'undefined') return wx
} catch (_) {
/* ignore */
}
try {
if (name === 'uni' && typeof uni !== 'undefined') return uni
} catch (_) {
/* ignore */
}
return undefined
}
export function getOaPrintHostApis(win = typeof window !== 'undefined' ? window : undefined) {
return {
win,
wxApi: resolveGlobalApi(win, 'wx'),
uniApi: resolveGlobalApi(win, 'uni')
}
}
export function isWeChatMiniWebView(win = typeof window !== 'undefined' ? window : undefined) {
const { wxApi } = getOaPrintHostApis(win)
try {
if (win && win.__wxjs_environment === 'miniprogram') return true
} catch (_) {
/* ignore */
}
try {
const ua =
(win && win.navigator && win.navigator.userAgent) ||
(typeof navigator !== 'undefined' ? navigator.userAgent : '') ||
''
if (/miniProgram/i.test(String(ua))) return true
} catch (_) {
/* ignore */
}
try {
if (wxApi && wxApi.miniProgram) return true
} catch (_) {
/* ignore */
}
return false
}
/** 预览页默认文件名;真实中文名由 download-url 按附件记录回填 */
export function asciiPdfFileName(fileName) {
void fileName
return 'approval.pdf'
}
/**
* 短 query:仅 fileUrl + f(兼容大小写吞参);不传中文 name,避免撑爆 navigateTo
* @returns {{ path: string, queryLen: number, fileUrlLen: number, name: string }}
*/
export function buildHostPdfPreviewPath({ fileUrl, fileName } = {}) {
const key = String(fileUrl || '').trim()
const name = asciiPdfFileName(fileName)
const q =
'fileUrl=' +
encodeURIComponent(key) +
'&f=' +
encodeURIComponent(key)
const path = '/pages/oa/pdf-preview/index?' + q
return {
path,
queryLen: q.length,
fileUrlLen: key.length,
name
}
}
/**
* @returns {Promise<{ ok: boolean, via?: string, path?: string, message?: string }>}
*/
export function navigateToHostPdfPreview({ fileUrl, fileName } = {}, deps = {}) {
const { path, queryLen, fileUrlLen, name } = buildHostPdfPreviewPath({
fileUrl,
fileName
})
const win =
deps.window !== undefined
? deps.window
: typeof window !== 'undefined'
? window
: undefined
const log = deps.log || printNavLog
const { wxApi, uniApi } = getOaPrintHostApis(win)
log('navigate start', {
fileUrlLen,
queryLen,
pathLen: path.length,
name,
fileUrlPreview: String(fileUrl || '').slice(0, 120),
hasWindowWx: !!(win && win.wx),
hasWxMiniNavigate: !!(wxApi && wxApi.miniProgram && wxApi.miniProgram.navigateTo),
hasUniWebViewNavigate: !!(uniApi && uniApi.webView && uniApi.webView.navigateTo),
__wxjs_environment: win && win.__wxjs_environment
})
if (!String(fileUrl || '').trim()) {
log('navigate abort: empty fileUrl')
return Promise.resolve({ ok: false, message: '缺少 fileUrl' })
}
// 微信建议 navigateTo url < 1024;超出仍尝试但打警告
if (path.length > 900) {
log('warn path long', { pathLen: path.length })
}
/** jweixin 的 miniProgram.navigateTo 常无 success/fail,超时视为已调用成功 */
const invokeNavigate = (label, navigateFn) =>
new Promise((resolve) => {
if (typeof navigateFn !== 'function') {
resolve(null)
return
}
let settled = false
const done = (result) => {
if (settled) return
settled = true
resolve(result)
}
try {
navigateFn({
url: path,
success: (res) => {
log(label + ' success', res)
done({ ok: true, via: label, path })
},
fail: (err) => {
log(label + ' fail', err)
done({
ok: false,
via: label,
path,
message: (err && (err.errMsg || err.message)) || 'navigateTo fail'
})
}
})
// 多数 web-view 环境无回调:短延时后按已发起处理
setTimeout(() => {
if (!settled) {
log(label + ' no-callback → assume ok', { pathLen: path.length })
done({ ok: true, via: label, path, assumed: true })
}
}, 80)
} catch (e) {
log(label + ' throw', String(e && e.message ? e.message : e))
done({
ok: false,
via: label,
path,
message: String(e && e.message ? e.message : e)
})
}
})
return invokeNavigate(
'wx.miniProgram.navigateTo',
wxApi && wxApi.miniProgram && wxApi.miniProgram.navigateTo
? wxApi.miniProgram.navigateTo.bind(wxApi.miniProgram)
: null
).then((wxRes) => {
if (wxRes && wxRes.ok) return wxRes
if (wxRes && wxRes.ok === false) {
log('wx failed, try uni', wxRes.message)
}
return invokeNavigate(
'uni.webView.navigateTo',
uniApi && uniApi.webView && uniApi.webView.navigateTo
? uniApi.webView.navigateTo.bind(uniApi.webView)
: null
).then((uniRes) => {
if (uniRes) return uniRes
if (wxRes) return wxRes
log('navigate unavailable')
return {
ok: false,
message: '无法跳转预览页:未找到 wx/uni navigateTo'
}
})
})
}
@@ -0,0 +1,33 @@
/**
* node --test 兼容的轻量断言(与 oa-print-filename.test.js 风格一致)
*/
import assert from 'node:assert/strict'
import {
asciiPdfFileName,
buildHostPdfPreviewPath,
isWeChatMiniWebView
} from './oa-pdf-preview-nav.js'
assert.equal(asciiPdfFileName('张三提交的请假.pdf'), 'approval.pdf')
assert.equal(asciiPdfFileName('report.PDF'), 'approval.pdf')
assert.equal(asciiPdfFileName('plain'), 'approval.pdf')
const built = buildHostPdfPreviewPath({
fileUrl: 'oa-approval/202608/abc.pdf',
fileName: '张三提交的请假审批.pdf'
})
assert.ok(built.path.startsWith('/pages/oa/pdf-preview/index?'))
assert.ok(built.path.includes('fileUrl='))
assert.ok(built.path.includes('&f='))
assert.ok(!built.path.includes('name='))
assert.ok(!built.path.includes('%E5%BC%A0'))
assert.ok(built.queryLen < 400)
assert.ok(built.path.length < 500)
assert.equal(
isWeChatMiniWebView({ __wxjs_environment: 'miniprogram' }),
true
)
assert.equal(isWeChatMiniWebView({}), false)
console.log('oa-pdf-preview-nav: ok')
+21
View File
@@ -116,6 +116,27 @@
line-height: 22px;
}
/*
* 人员/部门/岗位等选择触发条:可编辑态摘要过长时省略,避免撑出右侧。
* 详情只读走 .oa-pick__readonly(可换行),不经过 input。
*/
.form-create-m .oa-pick > .van-cell .van-field__control,
.form-create-m .oa-recv > .van-cell .van-field__control {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-create-m .oa-pick__readonly {
padding: 2px 0 0;
color: var(--text-primary, #0f291e);
font-size: 13px;
line-height: 1.55;
word-break: break-all;
overflow-wrap: anywhere;
text-align: left;
}
/* 归属人 / 归属部门锁定:箭头弱化,值色已由上方 disabled 规则统一为正文色 */
.form-create-m .oa-owner-field.is-locked .van-cell__right-icon {
color: #cbd5e1;
+27 -3
View File
@@ -347,12 +347,28 @@ const commentPlaceholder = computed(() => {
return '审批意见(可选)'
})
function resolveFallbackMiniPath() {
const p = String(route.query.returnPath || '').trim()
if (p.indexOf('/pages/') === 0) return p
return '/pages/oa/pending/index'
}
/** 通知宿主关闭(对齐 OaStart closeToMini) */
function closeToMini(payload) {
closeToHost(payload || { action: 'oa-detail-close' }, {
showToast: (msg) => showToast(msg),
fallbackMessage: '已关闭(非小程序环境)'
const data = payload || { action: 'oa-detail-close' }
const fallbackMiniPath = resolveFallbackMiniPath()
console.log('[oa-detail] closeToMini', {
action: data.action,
message: data.message,
fallbackMiniPath,
returnPath: route.query.returnPath || ''
})
const mode = closeToHost(data, {
showToast: (msg) => showToast(msg),
fallbackMessage: '已关闭(非小程序环境)',
fallbackMiniPath
})
console.log('[oa-detail] closeToHost →', mode)
}
function onClose() {
@@ -367,11 +383,17 @@ async function onPrint() {
currentUserName.value ||
String(detail.value?.initiatorUserName || '').trim() ||
'-'
console.log('[oa-detail] print start', {
instanceId: instanceId.value,
printerName
})
const result = await exportOaApprovalPdf(detail.value, { printerName })
console.log('[oa-detail] print result', result)
if (!result?.ok) {
showToast(result?.message || '导出 PDF 失败')
}
} catch (e) {
console.log('[oa-detail] print throw', e)
showToast(e?.message || '导出 PDF 失败')
} finally {
printing.value = false
@@ -417,6 +439,8 @@ function goCopyRestart() {
const q = { appId, fromInstanceId: id }
const token = String(route.query.token || '').trim()
if (token) q.token = token
const returnPath = String(route.query.returnPath || '').trim()
if (returnPath) q.returnPath = returnPath
try {
router.replace({ path: '/oa-start', query: q })
return
+19 -3
View File
@@ -186,12 +186,28 @@ const tripDaysManualFields = computed(() => {
return fields
})
function resolveFallbackMiniPath() {
const p = String(route.query.returnPath || '').trim()
if (p.indexOf('/pages/') === 0) return p
return '/pages/oa/start/index'
}
/** 通知宿主(小程序 web-view / uni-app H5 iframe)关闭并返回发起列表 */
function closeToMini(payload) {
closeToHost(payload || { action: 'oa-start-close' }, {
showToast: (msg) => showToast(msg),
fallbackMessage: '已完成(非小程序环境)'
const data = payload || { action: 'oa-start-close' }
const fallbackMiniPath = resolveFallbackMiniPath()
console.log('[oa-start] closeToMini', {
action: data.action,
message: data.message,
fallbackMiniPath,
returnPath: route.query.returnPath || ''
})
const mode = closeToHost(data, {
showToast: (msg) => showToast(msg),
fallbackMessage: '已完成(非小程序环境)',
fallbackMiniPath
})
console.log('[oa-start] closeToHost →', mode)
}
async function loadCurrentUser() {
@@ -0,0 +1,664 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>OA 列表 ·「管理」避开微信胶囊 Demo</title>
<style>
:root {
--brand: #0a9f6d;
--brand-dark: #087f57;
--brand-soft: #e9f9f2;
--warn: #d97706;
--warn-soft: #fff7e8;
--danger: #ef4444;
--nav-bg: #ecfdf5;
--page-bg: #f5f8f7;
--text: #1f2a26;
--text-muted: #6b7b74;
--border: #e8eaed;
--capsule-w: 87px;
--capsule-h: 32px;
--capsule-right: 7px;
--capsule-gap: 8px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", -apple-system, system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(900px 420px at 8% -8%, rgba(10, 159, 109, 0.12), transparent 55%),
linear-gradient(180deg, #f8fffc 0%, #f4f7fb 50%, #f7f8fa 100%);
line-height: 1.45;
}
.stage { max-width: 1240px; margin: 0 auto; padding: 28px 16px 64px; }
.hero h1 { margin: 0 0 8px; font-size: 22px; font-weight: 720; }
.hero p { margin: 0; color: #64748b; font-size: 14px; max-width: 880px; }
.rec {
margin: 16px 0 0;
padding: 14px 16px;
border-radius: 14px;
background: var(--brand-soft);
border: 1px solid #b7ebd4;
font-size: 13px;
color: #166534;
line-height: 1.55;
}
.rec b { color: var(--brand-dark); }
.tabs {
display: flex; flex-wrap: wrap; gap: 10px;
margin: 20px 0 18px;
}
.tab {
appearance: none; border: 1px solid #e2e8f0; background: #fff;
border-radius: 999px; padding: 10px 16px; font-size: 13px;
font-weight: 600; color: #475569; cursor: pointer;
}
.tab.is-active {
color: var(--brand-dark); border-color: var(--brand);
background: var(--brand-soft);
box-shadow: 0 0 0 3px rgba(10, 159, 109, 0.16);
}
.tab .badge {
margin-left: 6px; font-size: 11px; font-weight: 700;
color: var(--brand-dark); background: #d1fae5;
border-radius: 999px; padding: 2px 7px;
}
.tab .badge.warn {
color: #92400e; background: #fef3c7;
}
.layout {
display: grid;
grid-template-columns: minmax(240px, 320px) 1fr;
gap: 20px; align-items: start;
}
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
.panel {
background: rgba(255,255,255,.94);
border: 1px solid #e2e8f0; border-radius: 16px; padding: 16px;
box-shadow: 0 10px 28px rgba(15,23,42,.05);
}
.panel h2 { margin: 0 0 4px; font-size: 15px; }
.panel .sub { margin: 0 0 10px; font-size: 12px; color: #64748b; }
.panel ul { margin: 0; padding-left: 18px; font-size: 13px; color: #475569; }
.panel li { margin: 6px 0; }
.panel .pick {
margin-top: 12px; padding: 10px 12px; border-radius: 10px;
background: #fff7e8; border: 1px solid #fde68a;
font-size: 12px; color: #92400e;
}
.panel .ok {
margin-top: 10px; padding: 10px 12px; border-radius: 10px;
background: #ecfdf5; border: 1px solid #a7f3d0;
font-size: 12px; color: #047857;
}
.panel .bad {
margin-top: 10px; padding: 10px 12px; border-radius: 10px;
background: #fef2f2; border: 1px solid #fecaca;
font-size: 12px; color: #b91c1c;
}
.phones { display: flex; flex-wrap: wrap; gap: 18px; justify-content: center; }
.phone {
width: 375px; background: #fff; border-radius: 28px; overflow: hidden;
box-shadow: 0 0 0 1px rgb(15 23 42 / 6%), 0 18px 40px rgb(15 23 42 / 12%);
position: relative;
}
.cap {
text-align: center; font-size: 11px; font-weight: 650; color: #64748b;
padding: 8px; background: #f8fafc; border-bottom: 1px solid #eef2f0;
}
/* 模拟系统状态栏 + 自定义导航 */
.sys-bar {
height: 44px;
background: var(--nav-bg);
display: flex;
align-items: flex-end;
justify-content: space-between;
padding: 0 16px 6px;
font-size: 12px;
font-weight: 650;
color: #0f291e;
position: relative;
}
.sys-bar__time { font-variant-numeric: tabular-nums; }
.sys-bar__icons { opacity: 0.75; letter-spacing: 2px; font-size: 11px; }
.nav-wrap {
background: var(--nav-bg);
border-bottom: 1px solid #d8f3e5;
position: relative;
}
.nav {
height: 44px;
display: flex;
align-items: center;
padding: 0 8px 0 4px;
position: relative;
}
.nav.is-capsule-safe {
padding-right: calc(var(--capsule-w) + var(--capsule-right) + var(--capsule-gap));
}
.nav-back {
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
color: #0f291e; flex-shrink: 0;
}
.nav-back::before {
content: ""; width: 10px; height: 10px;
border-left: 2px solid #0f291e; border-bottom: 2px solid #0f291e;
transform: rotate(45deg); margin-left: 4px;
}
.nav-title {
position: absolute; left: 50%; transform: translateX(-50%);
font-size: 16px; font-weight: 650; color: #0f291e;
white-space: nowrap;
}
.nav-action {
margin-left: auto;
min-width: 48px;
height: 36px;
display: flex; align-items: center; justify-content: center;
padding: 0 8px;
font-size: 14px; font-weight: 650; color: #166534;
cursor: pointer; user-select: none;
position: relative; z-index: 2;
}
.nav-action.is-done { color: #64748b; }
.nav-action.is-hit {
outline: 2px solid var(--danger);
outline-offset: 1px;
border-radius: 6px;
background: rgba(239, 68, 68, 0.08);
}
.nav-action.is-safe {
outline: 2px solid var(--brand);
outline-offset: 1px;
border-radius: 6px;
background: rgba(10, 159, 109, 0.08);
}
/* 微信右上角胶囊(模拟) */
.wx-capsule {
position: absolute;
top: 6px;
right: var(--capsule-right);
width: var(--capsule-w);
height: var(--capsule-h);
border-radius: 16px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(0, 0, 0, 0.12);
box-shadow: 0 1px 2px rgba(0,0,0,.04);
display: flex;
align-items: center;
z-index: 50;
pointer-events: none;
overflow: hidden;
}
.wx-capsule__dots,
.wx-capsule__close {
flex: 1;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #111;
font-size: 13px;
font-weight: 700;
}
.wx-capsule__dots { gap: 3px; }
.wx-capsule__dots span {
width: 4px; height: 4px; border-radius: 50%; background: #111;
}
.wx-capsule__divider {
width: 1px; height: 18px; background: rgba(0,0,0,.12);
}
.wx-capsule__close { font-size: 15px; line-height: 1; }
.wx-capsule.is-warn {
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.45);
}
.wx-capsule.is-ok {
box-shadow: 0 0 0 2px rgba(10, 159, 109, 0.35);
}
.hit-label {
position: absolute;
top: 46px;
right: 10px;
z-index: 40;
font-size: 10px;
font-weight: 700;
padding: 3px 8px;
border-radius: 999px;
background: #fee2e2;
color: #b91c1c;
border: 1px solid #fecaca;
}
.safe-label {
position: absolute;
top: 46px;
right: 10px;
z-index: 40;
font-size: 10px;
font-weight: 700;
padding: 3px 8px;
border-radius: 999px;
background: #d1fae5;
color: #047857;
border: 1px solid #a7f3d0;
}
.toolbar {
background: #fff;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.search {
height: 36px;
border-radius: 10px;
border: 1px solid #e8eaed;
background: #fafafa;
display: flex;
align-items: center;
padding: 0 12px;
gap: 8px;
color: #94a3b8;
font-size: 13px;
}
.search::before {
content: "";
width: 14px; height: 14px;
border: 2px solid #94a3b8;
border-radius: 50%;
box-sizing: border-box;
position: relative;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 8px;
}
.toolbar-row .search { flex: 1; }
.manage-chip {
flex-shrink: 0;
height: 36px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid #a7f3d0;
background: #ecfdf5;
color: #166534;
font-size: 13px;
font-weight: 650;
display: flex;
align-items: center;
cursor: pointer;
}
.manage-chip.is-done {
border-color: #e2e8f0;
background: #f8fafc;
color: #64748b;
}
.list-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px 6px;
background: var(--page-bg);
}
.list-head__count {
font-size: 12px;
color: var(--text-muted);
}
.list-head__count strong { color: var(--text); font-weight: 700; }
.list-head__link {
font-size: 13px;
font-weight: 650;
color: #166534;
cursor: pointer;
padding: 4px 2px;
}
.list-head__link.is-done { color: #64748b; }
.list-head__link.is-safe {
outline: 2px solid var(--brand);
outline-offset: 2px;
border-radius: 6px;
background: rgba(10, 159, 109, 0.08);
}
.list {
background: var(--page-bg);
padding: 8px 12px 20px;
min-height: 320px;
}
.card {
background: #fff;
border-radius: 14px;
border: 1px solid #eef2f0;
padding: 12px 14px;
margin-bottom: 10px;
box-shadow: 0 4px 12px rgba(15,23,42,.04);
}
.card__title {
font-size: 14px; font-weight: 650; color: #0f291e;
margin-bottom: 6px;
}
.card__meta { font-size: 12px; color: var(--text-muted); }
.card.is-manage {
display: flex; align-items: center; gap: 10px;
}
.check {
width: 20px; height: 20px; border-radius: 50%;
border: 2px solid #cbd5e1; flex-shrink: 0;
}
.check.on {
border-color: var(--brand);
background: var(--brand);
box-shadow: inset 0 0 0 3px #fff;
}
.card__body { flex: 1; min-width: 0; }
.legend {
margin-top: 16px;
display: flex; flex-wrap: wrap; gap: 10px;
font-size: 12px; color: #64748b;
}
.legend span {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 10px; border-radius: 8px; background: #fff;
border: 1px solid #e2e8f0;
}
.dot {
width: 10px; height: 10px; border-radius: 50%;
}
.dot.hit { background: #ef4444; }
.dot.ok { background: #0a9f6d; }
.dot.cap { background: #94a3b8; border: 1px solid #64748b; }
.note {
margin-top: 18px;
font-size: 12px;
color: #64748b;
max-width: 880px;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
background: #f1f5f9;
padding: 1px 5px;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="stage">
<div class="hero">
<h1>OA 列表 ·「管理」避开微信小程序胶囊</h1>
<p>
影响页面:自定义导航的 <b>草稿箱 / 待办中心 / 抄送我的</b>
(已办中心、我发起的目前用系统导航且无「管理」)。
右上角模拟微信「··· / 关闭」胶囊;红框=被遮挡,绿框=可点区域安全。
</p>
<div class="rec">
<b>建议:</b>优先选 <b>A · 顶栏保留「管理」+ 按胶囊宽度留白</b>
(改动最小,交互习惯不变)。若希望管理与列表语义更近,可选
<b>B · 列表头「共 N 条 | 管理」</b>。
</div>
</div>
<div class="tabs" id="tabs">
<button type="button" class="tab is-active" data-k="now">① 现状·被遮挡<span class="badge warn">问题</span></button>
<button type="button" class="tab" data-k="a">② A · 顶栏留白<span class="badge">推荐</span></button>
<button type="button" class="tab" data-k="b">③ B · 列表头管理</button>
<button type="button" class="tab" data-k="c">④ C · 搜索旁胶囊</button>
</div>
<div class="layout">
<aside class="panel" id="info">
<h2>—</h2>
<p class="sub">—</p>
<ul></ul>
<div class="pick" hidden></div>
<div class="ok" hidden></div>
<div class="bad" hidden></div>
</aside>
<div>
<div class="phones" id="phones"></div>
<div class="legend">
<span><i class="dot cap"></i>微信胶囊(不可覆盖)</span>
<span><i class="dot hit"></i>「管理」与胶囊冲突</span>
<span><i class="dot ok"></i>「管理」可安全点击</span>
</div>
</div>
</div>
<p class="note">
实现提示:方案 A 用 <code>uni.getMenuButtonBoundingClientRect()</code>
(或微信 <code>wx.getMenuButtonBoundingClientRect</code>)取胶囊
<code>width / left / right</code>,给 <code>.nav__inner</code> 增加
<code>padding-right</code>,把「管理」推到胶囊左侧并留约 8px 间隙。
非微信环境可回退为固定约 96px 右内边距。
</p>
</div>
<script>
var SCENES = {
now: {
title: "① 现状 · 顶栏右侧「管理」被胶囊遮住",
sub: "自定义 navigationStyle,仅处理了 statusBar,未避开胶囊。",
items: [
"「管理」贴在导航栏最右侧(margin-left: auto)",
"微信胶囊叠在同一垂直带上,文字被挡住、难以点击",
"草稿箱 / 待办 / 抄送三页结构相同"
],
bad: "不要继续这样落地:真机上关闭区会盖住「管理」。",
phoneCap: "现状 · 冲突",
mode: "now"
},
a: {
title: "② A · 顶栏保留「管理」+ 胶囊安全留白(推荐)",
sub: "仍在标题栏右侧,但右侧 padding 避开胶囊宽度。",
items: [
"交互位置不变:用户仍在顶栏找「管理 / 完成」",
"用 menuButtonBoundingClientRect 动态算右侧安全区",
"改动集中在公共顶栏样式 / 工具函数,三页可复用",
"标题仍居中,不会挤到胶囊上"
],
ok: "推荐:最小改动、符合现有 Demo 约定(顶栏管理)。",
phoneCap: "方案 A · 顶栏留白",
mode: "a"
},
b: {
title: "③ B · 列表头「共 N 条 | 管理」",
sub: "顶栏只留返回+标题;管理下移到搜索下方次级行。",
items: [
"彻底避开胶囊,无需计算胶囊宽度",
"管理与列表批量操作语义更近(对齐早期美化 Demo S1)",
"顶栏更干净,但入口位置相对现状下移",
"三页需改模板结构(多一行 list-head)"
],
pick: "想弱化顶栏操作、强调列表批量时选 B。",
phoneCap: "方案 B · 列表头",
mode: "b"
},
c: {
title: "④ C · 搜索框右侧胶囊按钮",
sub: "顶栏无「管理」;与搜索同排的描边小按钮。",
items: [
"同样避开胶囊,实现简单",
"搜索区会变窄,长 placeholder 更易截断",
"早期 Demo 曾认为「挤在搜索旁偏突兀」"
],
pick: "仅作备选;一般不如 A / B。",
phoneCap: "方案 C · 搜索旁",
mode: "c"
}
}
var cards = [
{ title: "差旅报销申请", meta: "张三 · 技术中心 · 2 小时前" },
{ title: "采购合同审批", meta: "李四 · 采购部 · 昨天" },
{ title: "请假申请", meta: "王五 · 行政 · 周一" }
]
function wxCapsule(extraClass) {
return (
'<div class="wx-capsule ' + (extraClass || "") + '" aria-hidden="true">' +
'<div class="wx-capsule__dots"><span></span><span></span><span></span></div>' +
'<div class="wx-capsule__divider"></div>' +
'<div class="wx-capsule__close">✕</div>' +
'</div>'
)
}
function cardHtml(manage) {
return cards.map(function (c, i) {
if (!manage) {
return (
'<div class="card">' +
'<div class="card__title">' + c.title + '</div>' +
'<div class="card__meta">' + c.meta + '</div>' +
'</div>'
)
}
return (
'<div class="card is-manage">' +
'<div class="check' + (i === 0 ? ' on' : '') + '"></div>' +
'<div class="card__body">' +
'<div class="card__title">' + c.title + '</div>' +
'<div class="card__meta">' + c.meta + '</div>' +
'</div>' +
'</div>'
)
}).join("")
}
function phoneShell(label, bodyInner, flag) {
var labelHtml = ""
if (flag === "hit") labelHtml = '<div class="hit-label">被胶囊遮挡</div>'
if (flag === "safe") labelHtml = '<div class="safe-label">可安全点击</div>'
return (
'<div class="phone">' +
'<div class="cap">' + label + '</div>' +
'<div class="sys-bar">' +
'<span class="sys-bar__time">9:41</span>' +
'<span class="sys-bar__icons">▮▮▮ ░</span>' +
'</div>' +
'<div class="nav-wrap">' +
bodyInner +
(flag === "hit" ? wxCapsule("is-warn") : wxCapsule("is-ok")) +
labelHtml +
'</div>' +
'</div>'
)
}
function setInfo(key) {
var s = SCENES[key]
var info = document.getElementById("info")
info.querySelector("h2").textContent = s.title
info.querySelector(".sub").textContent = s.sub
info.querySelector("ul").innerHTML = s.items
.map(function (t) { return "<li>" + t + "</li>" })
.join("")
;["pick", "ok", "bad"].forEach(function (cls) {
var el = info.querySelector("." + cls)
if (s[cls]) {
el.hidden = false
el.textContent = s[cls]
} else {
el.hidden = true
el.textContent = ""
}
})
}
function buildPhone(mode, manage) {
var actionText = manage ? "完成" : "管理"
var actionCls = manage ? "nav-action is-done" : "nav-action"
var list = '<div class="list">' + cardHtml(manage) + "</div>"
var inner = ""
var flag = "safe"
var label = ""
if (mode === "now") {
flag = "hit"
label = manage ? "现状 · 管理态(冲突)" : "现状 · 普通态(冲突)"
inner =
'<div class="nav">' +
'<div class="nav-back"></div>' +
'<div class="nav-title">待办中心</div>' +
'<div class="' + actionCls + ' is-hit">' + actionText + "</div>" +
"</div>" +
'<div class="toolbar"><div class="search">搜索流程名称、申请人</div></div>' +
list
} else if (mode === "a") {
label = manage ? "A · 管理态" : "A · 普通态"
inner =
'<div class="nav is-capsule-safe">' +
'<div class="nav-back"></div>' +
'<div class="nav-title">待办中心</div>' +
'<div class="' + actionCls + ' is-safe">' + actionText + "</div>" +
"</div>" +
'<div class="toolbar"><div class="search">搜索流程名称、申请人</div></div>' +
list
} else if (mode === "b") {
label = manage ? "B · 管理态" : "B · 普通态"
var linkCls = manage
? "list-head__link is-done is-safe"
: "list-head__link is-safe"
inner =
'<div class="nav">' +
'<div class="nav-back"></div>' +
'<div class="nav-title">待办中心</div>' +
"</div>" +
'<div class="toolbar"><div class="search">搜索流程名称、申请人</div></div>' +
'<div class="list-head">' +
'<div class="list-head__count">共 <strong>3</strong> 条</div>' +
'<div class="' + linkCls + '">' + actionText + "</div>" +
"</div>" +
list
} else {
label = manage ? "C · 管理态" : "C · 普通态"
var chipCls = manage ? "manage-chip is-done" : "manage-chip"
inner =
'<div class="nav">' +
'<div class="nav-back"></div>' +
'<div class="nav-title">待办中心</div>' +
"</div>" +
'<div class="toolbar">' +
'<div class="toolbar-row">' +
'<div class="search">搜索流程名称、申请人</div>' +
'<div class="' + chipCls + '">' + actionText + "</div>" +
"</div>" +
"</div>" +
list
}
return phoneShell(label, inner, flag)
}
function render(key) {
var s = SCENES[key]
document.getElementById("phones").innerHTML =
buildPhone(s.mode, false) + buildPhone(s.mode, true)
setInfo(key)
}
document.getElementById("tabs").addEventListener("click", function (e) {
var btn = e.target.closest(".tab")
if (!btn) return
document.querySelectorAll(".tab").forEach(function (t) {
t.classList.toggle("is-active", t === btn)
})
render(btn.getAttribute("data-k"))
})
render("now")
</script>
</body>
</html>
@@ -0,0 +1,460 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>OA 列表 · 搜索后方管理 Icon 选型</title>
<style>
:root {
--brand: #0a9f6d;
--brand-dark: #087f57;
--brand-soft: #e9f9f2;
--nav-bg: #ecfdf5;
--page-bg: #f5f8f7;
--text: #1f2a26;
--text-muted: #6b7b74;
--border: #e8eaed;
--capsule-w: 87px;
--capsule-h: 32px;
--capsule-right: 7px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", -apple-system, system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(900px 420px at 8% -8%, rgba(10, 159, 109, 0.12), transparent 55%),
linear-gradient(180deg, #f8fffc 0%, #f4f7fb 50%, #f7f8fa 100%);
line-height: 1.45;
}
.stage { max-width: 1280px; margin: 0 auto; padding: 28px 16px 64px; }
.hero h1 { margin: 0 0 8px; font-size: 22px; font-weight: 720; }
.hero p { margin: 0; color: #64748b; font-size: 14px; max-width: 900px; }
.rec {
margin: 16px 0 0;
padding: 14px 16px;
border-radius: 14px;
background: var(--brand-soft);
border: 1px solid #b7ebd4;
font-size: 13px;
color: #166534;
line-height: 1.55;
}
.rec b { color: var(--brand-dark); }
.mode-tabs {
display: flex; flex-wrap: wrap; gap: 10px;
margin: 20px 0 8px;
}
.mode-tab {
appearance: none; border: 1px solid #e2e8f0; background: #fff;
border-radius: 999px; padding: 10px 16px; font-size: 13px;
font-weight: 600; color: #475569; cursor: pointer;
}
.mode-tab.is-active {
color: var(--brand-dark); border-color: var(--brand);
background: var(--brand-soft);
box-shadow: 0 0 0 3px rgba(10, 159, 109, 0.16);
}
.hint {
margin: 0 0 16px;
font-size: 13px;
color: #64748b;
}
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 18px;
}
.option {
background: rgba(255,255,255,.94);
border: 2px solid #e2e8f0;
border-radius: 18px;
padding: 12px;
cursor: pointer;
transition: border-color .15s, box-shadow .15s;
}
.option:hover { border-color: #a7f3d0; }
.option.is-selected {
border-color: var(--brand);
box-shadow: 0 0 0 4px rgba(10, 159, 109, 0.18);
background: #f0fdf7;
}
.option__head {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; margin-bottom: 10px; padding: 0 4px;
}
.option__name { font-size: 14px; font-weight: 700; color: #0f291e; }
.option__tag {
font-size: 11px; font-weight: 700; padding: 3px 8px; border-radius: 999px;
background: #f1f5f9; color: #64748b;
}
.option.is-selected .option__tag {
background: #d1fae5; color: #047857;
}
.option__desc {
margin: 0 0 10px; padding: 0 4px;
font-size: 12px; color: #64748b;
}
.phone {
width: 100%; max-width: 320px; margin: 0 auto;
background: #fff; border-radius: 22px; overflow: hidden;
box-shadow: 0 0 0 1px rgb(15 23 42 / 6%), 0 12px 28px rgb(15 23 42 / 10%);
position: relative;
pointer-events: none;
}
.sys-bar {
height: 36px; background: var(--nav-bg);
display: flex; align-items: flex-end; justify-content: space-between;
padding: 0 14px 4px; font-size: 11px; font-weight: 650; color: #0f291e;
}
.nav-wrap {
background: var(--nav-bg);
border-bottom: 1px solid #d8f3e5;
position: relative;
}
.nav {
height: 40px; display: flex; align-items: center;
padding: 0 8px 0 4px; position: relative;
}
.nav-back {
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
}
.nav-back::before {
content: ""; width: 9px; height: 9px;
border-left: 2px solid #0f291e; border-bottom: 2px solid #0f291e;
transform: rotate(45deg); margin-left: 4px;
}
.nav-title {
position: absolute; left: 50%; transform: translateX(-50%);
font-size: 15px; font-weight: 650; color: #0f291e;
}
.wx-capsule {
position: absolute; top: 4px; right: 6px;
width: 74px; height: 28px;
border-radius: 14px; background: rgba(255,255,255,.92);
border: 1px solid rgba(0,0,0,.12);
display: flex; align-items: center; z-index: 50;
}
.wx-capsule__dots, .wx-capsule__close {
flex: 1; height: 100%; display: flex; align-items: center; justify-content: center;
color: #111; font-size: 11px; font-weight: 700;
}
.wx-capsule__dots { gap: 2px; }
.wx-capsule__dots span {
width: 3px; height: 3px; border-radius: 50%; background: #111;
}
.wx-capsule__divider { width: 1px; height: 14px; background: rgba(0,0,0,.12); }
.toolbar {
background: #fff; padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.toolbar-row { display: flex; align-items: center; gap: 8px; }
.search {
flex: 1; min-width: 0; height: 34px; border-radius: 9px;
border: 1px solid #e8eaed; background: #fafafa;
display: flex; align-items: center; padding: 0 10px; gap: 6px;
color: #94a3b8; font-size: 12px;
}
.search::before {
content: ""; width: 12px; height: 12px; flex-shrink: 0;
border: 2px solid #94a3b8; border-radius: 50%; box-sizing: border-box;
}
.manage-ico-btn {
flex-shrink: 0;
width: 34px; height: 34px;
border-radius: 9px;
border: 1px solid #c6f0dc;
background: #ecfdf5;
color: #166534;
display: inline-flex;
align-items: center;
justify-content: center;
outline: 2px solid rgba(10, 159, 109, 0.45);
outline-offset: 1px;
}
.manage-ico-btn.is-done {
border-color: #e2e8f0;
background: #f8fafc;
color: #64748b;
outline-color: rgba(100, 116, 139, 0.35);
}
.manage-ico-btn svg { width: 17px; height: 17px; display: block; }
.pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.pair__label {
text-align: center;
font-size: 11px;
font-weight: 650;
color: #64748b;
margin-bottom: 4px;
}
.mini-phone {
background: #fff;
border: 1px solid #e8eeeb;
border-radius: 12px;
overflow: hidden;
}
.mini-bar {
background: var(--nav-bg);
height: 18px;
border-bottom: 1px solid #d8f3e5;
}
.mini-tool {
display: flex; align-items: center; gap: 6px;
padding: 6px;
background: #fff;
}
.mini-search {
flex: 1; height: 26px; border-radius: 7px;
border: 1px solid #e8eaed; background: #fafafa;
}
.mini-ico {
width: 26px; height: 26px; border-radius: 7px;
border: 1px solid #c6f0dc; background: #ecfdf5; color: #166534;
display: inline-flex; align-items: center; justify-content: center;
}
.mini-ico.is-done {
border-color: #e2e8f0; background: #f8fafc; color: #64748b;
}
.mini-ico svg { width: 14px; height: 14px; }
.mini-list { background: var(--page-bg); padding: 6px; min-height: 52px; }
.mini-card {
height: 18px; border-radius: 6px; background: #fff;
border: 1px solid #eef2f0; margin-bottom: 4px;
}
.footer-pick {
position: sticky;
bottom: 12px;
margin-top: 20px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(15, 41, 30, 0.92);
color: #ecfdf5;
font-size: 13px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
box-shadow: 0 10px 28px rgba(15,23,42,.18);
}
.footer-pick strong { color: #6ee7b7; }
.footer-pick .muted { color: #94a3b8; font-size: 12px; }
</style>
</head>
<body>
<div class="stage">
<div class="hero">
<h1>搜索框后方 · 管理 Icon 选型</h1>
<p>
布局已定:顶栏不放「管理」;搜索框右侧放 36×36 icon。
下面每种 icon 都画出<strong>普通态 / 管理态(完成)</strong>对照,点选卡片即可选定。
</p>
<div class="rec">
<b>已选定:</b>搜索行 <code>[搜索框] [三点菜单 icon]</code>;
普通态 = 三点;管理态 = 勾选。已落地到草稿箱 / 待办 / 抄送三页。
</div>
</div>
<div class="mode-tabs">
<button type="button" class="mode-tab is-active" disabled>每种方案均含:普通态 + 管理态</button>
</div>
<p class="hint">绿框为当前可点区域示意;右上角仍保留微信胶囊,确认不会挡到 icon。</p>
<div class="gallery" id="gallery"></div>
<div class="footer-pick" id="footerPick">
<div>
当前选中:<strong id="pickedName">—</strong>
<div class="muted" id="pickedDesc">点击上方卡片选择 icon</div>
</div>
<div class="muted">点选后回复方案名即可落地</div>
</div>
</div>
<script>
var CHECK =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M5 12.5l4.2 4.2L19 7"/></svg>'
var OPTIONS = [
{
id: "more",
name: "三点菜单",
tag: "已落地",
desc: "入口克制;真机用三个圆点,兼容性好。完成态换勾。",
normal:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' +
'<circle cx="6" cy="12" r="1.4" fill="currentColor" stroke="none"/>' +
'<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/>' +
'<circle cx="18" cy="12" r="1.4" fill="currentColor" stroke="none"/>' +
'</svg>'
},
{
id: "gear",
name: "齿轮设置",
tag: "备选",
desc: "真机 CSS 齿轮不稳定,已弃用。",
normal:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<circle cx="12" cy="12" r="3"/>' +
'<path d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/>' +
'</svg>'
},
{
id: "sliders",
name: "调参条",
tag: "偏整理",
desc: "「整理/批量操作」感;完成态换勾。",
normal:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M4 7h10M16 7h4M4 17h4M10 17h10M14 4v6M8 14v6"/>' +
'</svg>'
},
{
id: "layers",
name: "多层卡片",
tag: "偏批量",
desc: "暗示对多条记录操作;完成态换勾。",
normal:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M12 2l9 4.5-9 4.5L3 6.5 12 2z"/>' +
'<path d="M3 12.5l9 4.5 9-4.5"/>' +
'<path d="M3 17.5l9 4.5 9-4.5"/>' +
'</svg>'
},
{
id: "checklist",
name: "清单勾选",
tag: "偏多选",
desc: "直接表达「进入多选管理」;完成态换勾。",
normal:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M9 6h11M9 12h11M9 18h11"/>' +
'<path d="M4.5 6.5l1.2 1.2L7.8 5.5M4.5 12.5l1.2 1.2L7.8 11.5M4.5 18.5l1.2 1.2L7.8 17.5"/>' +
'</svg>'
},
{
id: "edit",
name: "铅笔编辑",
tag: "偏编辑",
desc: "草稿箱场景很贴切;待办/抄送也说得通。完成态换勾。",
normal:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M12 20h9"/>' +
'<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/>' +
'</svg>'
},
{
id: "square-check",
name: "方框勾选",
tag: "偏选择",
desc: "一眼是「可勾选」;管理态用实心勾强调完成。",
normal:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<rect x="4" y="4" width="16" height="16" rx="3"/>' +
'<path d="M8 12l2.8 2.8L16.5 9"/>' +
'</svg>'
}
]
var selected = "more"
function miniPair(opt) {
return (
'<div class="pair">' +
'<div>' +
'<div class="pair__label">普通态 · 管理</div>' +
'<div class="mini-phone">' +
'<div class="mini-bar"></div>' +
'<div class="mini-tool">' +
'<div class="mini-search"></div>' +
'<div class="mini-ico">' + opt.normal + '</div>' +
'</div>' +
'<div class="mini-list"><div class="mini-card"></div><div class="mini-card"></div></div>' +
'</div>' +
'</div>' +
'<div>' +
'<div class="pair__label">管理态 · 完成</div>' +
'<div class="mini-phone">' +
'<div class="mini-bar"></div>' +
'<div class="mini-tool">' +
'<div class="mini-search"></div>' +
'<div class="mini-ico is-done">' + CHECK + '</div>' +
'</div>' +
'<div class="mini-list"><div class="mini-card"></div><div class="mini-card"></div></div>' +
'</div>' +
'</div>' +
'</div>'
)
}
function fullPhone(opt, manage) {
var svg = manage ? CHECK : opt.normal
var cls = "manage-ico-btn" + (manage ? " is-done" : "")
return (
'<div class="phone">' +
'<div class="sys-bar"><span>9:41</span><span>▮▮</span></div>' +
'<div class="nav-wrap">' +
'<div class="nav"><div class="nav-back"></div><div class="nav-title">待办中心</div></div>' +
'<div class="wx-capsule" aria-hidden="true">' +
'<div class="wx-capsule__dots"><span></span><span></span><span></span></div>' +
'<div class="wx-capsule__divider"></div>' +
'<div class="wx-capsule__close">✕</div>' +
'</div>' +
'</div>' +
'<div class="toolbar"><div class="toolbar-row">' +
'<div class="search">搜索流程名称、申请人</div>' +
'<div class="' + cls + '">' + svg + '</div>' +
'</div></div>' +
'</div>'
)
}
function render() {
var root = document.getElementById("gallery")
root.innerHTML = OPTIONS.map(function (opt) {
var on = opt.id === selected ? " is-selected" : ""
var tag = opt.id === selected ? "已选" : opt.tag
return (
'<div class="option' + on + '" data-id="' + opt.id + '">' +
'<div class="option__head">' +
'<div class="option__name">' + opt.name + '</div>' +
'<div class="option__tag">' + tag + '</div>' +
'</div>' +
'<p class="option__desc">' + opt.desc + '</p>' +
fullPhone(opt, false) +
miniPair(opt) +
'</div>'
)
}).join("")
var cur = OPTIONS.find(function (o) { return o.id === selected })
document.getElementById("pickedName").textContent = cur ? cur.name : "—"
document.getElementById("pickedDesc").textContent = cur ? cur.desc : ""
}
document.getElementById("gallery").addEventListener("click", function (e) {
var card = e.target.closest(".option")
if (!card) return
selected = card.getAttribute("data-id")
render()
})
render()
</script>
</body>
</html>
+2 -2
View File
@@ -17,8 +17,8 @@
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/common/index.ts": 1781272556.3597305,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/directive/directive.d.ts": 1781272556.3601494,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/api/api.d.ts": 1781272556.3606477,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1786601057.3350704,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1786601019.5323057,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/components.d.ts": 1786601627.2922847,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/import/auto-imports.d.ts": 1786601590.8955057,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/store/index.ts": 1781272556.364766,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/types/router/index.ts": 1782394569.8806834,
"/Users/wuzilong/work/00code/zkxw/web-admin/src/composables/useAttachmentActions.ts": 1783921717.4015942,