提交
This commit is contained in:
1 parent
8c49021d38
commit
283b8cd1e3
33 files changed
+3904
-1423
No files matched your search
@@ -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,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
|
||||
|
||||
+639
-639
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user