Files
lucky_shop/pages_tool/files/detail.vue

590 lines
18 KiB
Vue

<template>
<view class="files-detail" :style="themeColor">
<view class="file-card">
<view class="pdf-icon">
<text class="pdf-icon-p">P</text>
<view class="pdf-icon-fold"></view>
<view class="pdf-icon-lines">
<view class="pdf-icon-line"></view>
<view class="pdf-icon-line"></view>
<view class="pdf-icon-line"></view>
</view>
</view>
<view class="info-wrap">
<view class="title">{{ displayTitle }}</view>
<view v-if="!loaded" class="read-wrap">
<text>{{ $lang('files.list.loading') }}</text>
</view>
</view>
</view>
<view v-if="fileUrl" class="action-area">
<button class="action-btn primary" @tap="openFile">{{ $lang('files.list.viewPdf') }}</button>
</view>
<view v-if="failed" class="error-tip">{{ $lang('files.list.fileNotExist') }}</view>
<loading-cover ref="loadingCover"></loading-cover>
<!-- 隐私弹窗 -->
<privacy-popup ref="privacyPopup"></privacy-popup>
</view>
</template>
<script>
/**
* 文件详情页 —— 承接 DIY 装修里的文件链接: /pages_tool/files/detail?files_id=43&title=xxx
*
* 背景: 后台装修数据(如"图标导航/文本导航"的 FILES_xx 链接)把文件指向本页,
* 此前页面缺失导致点击后打不开。本页负责:
* 1) 按 files_id 取到文件信息(files_url / files_title)
* 2) 进入页面即自动按平台打开文件(与 files/list 点击行为一致)
* 3) 自动打开失败时,可点击"查看PDF文件"按钮重试
*
* 注1: 本页文案只使用语言包中已存在的 key(files.list.*),避免新增 key 未同步到服务端
* (lang 为 remote-only 模式,运行时取 /api/lang/bundle)。
* 注2: Files 控制器没有单文件详情接口,只能从列表接口里按 files_id 取,详见 fetchFile()。
*/
export default {
data() {
return {
filesId: '',
filesTitle: '',
detail: null,
fileUrl: '',
loaded: false,
failed: false,
// 排查用:记录列表接口扫过的文件(files_id:标题)
scanned: [],
titleTimers: []
};
},
computed: {
displayTitle() {
if (this.filesTitle) return this.filesTitle;
if (this.detail && (this.detail.files_title || this.detail.title)) {
return this.detail.files_title || this.detail.title;
}
return this.$lang('files.list.title');
},
themeColor() {
if (this.$shop && this.$shop.themeColor) {
return `--theme-color: ${this.$shop.themeColor};`;
}
return '';
}
},
onLoad(options) {
options = options || {};
// page_params 可能是 JSON 字符串 / 对象 / 数组(装修数据里常见 [])
const params = this.$util.parsePageParams(options.page_params) || {};
const rawId = options.files_id || options.file_id || options.filesId || options.id
|| params.files_id || params.file_id || params.id || '';
this.filesId = rawId === '' || rawId === undefined || rawId === null ? '' : String(rawId);
const rawTitle = options.title || params.title || '';
if (rawTitle) this.filesTitle = this.decodeTitle(rawTitle);
this.scheduleTitleSync();
this.getData();
},
onUnload() {
this.clearTitleTimers();
},
watch: {
// main.js 的全局 mixin 监听 componentRefresh,变化时调用 $langConfig.refresh(),
// 而 refresh() 会用 $lang('title') 重置导航标题。本页标题就是文件名、且远端语言包里
// 没有 files/detail 词条(remote-only 模式),$lang('title') 会退化成字面量 "title"。
// refresh() 对每个组件都会跑一遍、"最后一个生效",所以这里延时补写几次兜底。
componentRefresh() {
if (this.isCurrentPage()) this.scheduleTitleSync();
}
},
onShareAppMessage() {
return {
title: this.displayTitle,
path: `/pages_tool/files/detail?files_id=${this.filesId}`
};
},
onShareTimeline() {
return {
title: this.displayTitle,
query: `files_id=${this.filesId}`
};
},
methods: {
syncNavigationTitle() {
this.$langConfig.title(this.displayTitle);
},
// 导航标题兜底:立即写一次,再延时补几次(等全局 refresh() 都跑完)
scheduleTitleSync() {
this.clearTitleTimers();
this.syncNavigationTitle();
[100, 500, 1500].forEach(delay => {
this.titleTimers.push(setTimeout(() => {
if (this.isCurrentPage()) this.syncNavigationTitle();
}, delay));
});
},
clearTitleTimers() {
this.titleTimers.forEach(id => clearTimeout(id));
this.titleTimers = [];
},
// 本页是否在最上层(避免本页在后台时去改别的页面标题)
isCurrentPage() {
try {
const pages = getCurrentPages();
const current = pages[pages.length - 1];
if (!current) return false;
const route = current.route || (current.$page && current.$page.path) || '';
return route.indexOf('files/detail') !== -1;
} catch (e) {
return false;
}
},
decodeTitle(value) {
const str = String(value || '');
// 多数端 uni 已自动 decode 一次,这里只在明显还是百分号编码时才解码,避免二次解码破坏文案
if (!str || str.indexOf('%') === -1) return str;
try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
},
hideLoadingCover() {
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
},
async getData() {
if (!this.filesId) {
this.finishLoad(null);
return;
}
// 优先走后端单文件详情接口: 按 files_id 直接取, 无需翻列表(文件多时省几十个请求)
let detail = await this.fetchGetfile(this.filesId);
// 兜底: 列表逐页扫(可能为空/被过滤) / 企业文件接口
if (!detail) detail = await this.fetchFile(this.filesId) || await this.fetchFromPersonnel();
if (!detail || !this.normalizeUrl(detail.files_url)) {
this.finishLoad(null);
this.$util.showToast({
title: this.$lang('files.list.fileNotExist'),
icon: 'none'
});
return;
}
this.detail = detail;
if (!this.filesTitle) this.filesTitle = detail.files_title || detail.title || '';
this.syncNavigationTitle();
this.fileUrl = this.normalizeUrl(detail.files_url);
this.finishLoad(detail);
// 进入详情页即自动打开,与点击文件列表项行为一致
this.openFile();
},
finishLoad(detail) {
this.loaded = true;
this.failed = !detail;
this.hideLoadingCover();
},
/**
* 取单个文件信息(优先链路)。
* 后端接口: GET /api/files/getfile?files_id=xxx
* 内部 getFilesInfo([['files_id','=',$files_id]]) → model('files')->getInfo(),
* 返回整条记录(files_title / files_url / files_desc / category_id 等),
* files_url 已 img() 补全域名; files 表带 site_id, bootSiteGuard 自动注入当前站点,多租户不串号。
* 仅 files_id 一个参数即可,后端无需改造。
*/
async fetchGetfile(filesId) {
const res = await this.request('/api/files/getfile', { files_id: filesId });
if (!res || res.code !== 0 || !res.data) return null;
const d = res.data;
return {
files_id: d.files_id || filesId,
files_title: d.files_title || d.title || '',
files_url: d.files_url || '',
files_desc: d.files_desc,
category_id: d.category_id,
category_name: d.category_name
};
},
/**
* 兜底链路(优先链路 fetchGetfile 失败时): 逐页拉列表按 files_id 精确匹配。
* 该 addon 之前 Files 控制器只提供列表接口(实测 /api/files/info、/detail、/getinfo 均 404:
* "method not exists:app\api\controller\Files->info()"),所以用 getpage 翻页扫。
*/
async fetchFile(filesId) {
// 1) 有标题时先拿标题当关键词搜一次(命中就 1 个请求解决,不必深翻页)
if (this.filesTitle) {
const searched = await this.queryList({ page: 1, page_size: 100, keyword: this.filesTitle }, filesId, true);
if (searched && searched.hit) return searched.hit;
}
// 2) 逐页遍历全量列表(后端只返回 10 条/页时最多翻 30 页)
for (let page = 1; page <= 30; page++) {
const queried = await this.queryList({ page: page, page_size: 100 }, filesId);
if (queried && queried.hit) return queried.hit;
if (!queried || queried.empty) return null;
}
return null;
},
/**
* 兜底链路: 从企业文件接口(/api/member/personnel, 电子名片页"企业文件"同源)取文件。
* 该接口返回 res.file_list, 每项形如 { name, url }(可能带 files_id/file_id/id)。
* 先按 files_id 精确匹配, 再按标题精确匹配, 最后按标题互相包含模糊匹配。
*/
async fetchFromPersonnel() {
const res = await this.request('/api/member/personnel', {});
if (!res || res.code !== 0) return null;
const list = res.file_list || (res.data && res.data.file_list) || [];
if (!Array.isArray(list)) return null;
const title = (this.filesTitle || '').toString();
const getId = item => item.files_id || item.file_id || item.id || '';
let fuzzyHit = null;
for (const item of list) {
if (!item) continue;
// 1) files_id 精确匹配
const id = getId(item);
if (id !== '' && String(id) === String(this.filesId)) return this.wrapPersonnelFile(item);
// 2) 标题精确匹配
const name = (item.name || item.files_title || item.title || '').toString();
if (title && name === title) return this.wrapPersonnelFile(item);
// 3) 标题互相包含(记下第一个命中, 优先级最低)
if (title && !fuzzyHit && (name.indexOf(title) !== -1 || title.indexOf(name) !== -1)) {
fuzzyHit = item;
}
}
return fuzzyHit ? this.wrapPersonnelFile(fuzzyHit) : null;
},
wrapPersonnelFile(item) {
return {
files_id: item.files_id || item.file_id || item.id || this.filesId,
files_title: item.name || item.files_title || item.title || '',
files_url: item.url || item.files_url || ''
};
},
/**
* 请求一页文件列表,并按 files_id 精确匹配(入参与 files/list 页保持一致)
* @param {Boolean} allowTitleMatch 该页是标题搜索结果时,允许退一步用标题全等匹配
* @returns {Object|null} 命中返回 { hit };本页无数据返回 { empty: true };请求失败返回 null
*/
async queryList(data, filesId, allowTitleMatch) {
const res = await this.request('/api/files/getpage', Object.assign({
category_id: '',
keyword: ''
}, data));
if (!res || res.code !== 0 || !res.data || !Array.isArray(res.data.list)) return null;
const list = res.data.list;
let titleHit = null;
for (const item of list) {
const src = item && item.$orig ? item.$orig : item;
if (!src) continue;
if (String(src.files_id) === String(filesId)) return { hit: src };
// 兜底:装修链接里的 files_id 与接口不一致时,用标题全等再认一次
if (allowTitleMatch && !titleHit && this.filesTitle && src.files_title === this.filesTitle) {
titleHit = src;
}
}
if (titleHit) return { hit: titleHit };
return { empty: list.length === 0 };
},
async request(url, data) {
try {
return await this.$api.sendRequest({
url,
data,
async: false,
noRetry: true,
// 失败静默返回 null,由调用方兜底,不弹全局报错
silentError: true
});
} catch (e) {
return null;
}
},
normalizeUrl(url) {
if (!url) return '';
const str = String(url);
if (/^https?:\/\//i.test(str)) return str;
// $util.img 对含逗号的路径会按"多图字段"截断,文件名可能含逗号,这里规避
if (str.indexOf(',') !== -1) return str;
// 相对路径补全域名(与图片同一套域名规则)
return this.$util.img(str);
},
getFileExt() {
let ext = 'pdf';
if (this.fileUrl) {
const clean = this.fileUrl.split('?')[0];
const matched = clean.match(/\.([a-zA-Z0-9]+)$/);
if (matched) ext = matched[1].toLowerCase();
}
return ext;
},
getFileName() {
const title = (this.displayTitle || 'file').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
return `${title}.${this.getFileExt()}`;
},
getFileType() {
const ext = this.getFileExt();
return ['pdf'].includes(ext) ? 'pdf'
: ['doc', 'docx'].includes(ext) ? 'word'
: ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'].includes(ext) ? 'video'
: 'pdf';
},
buildPreviewUrl() {
const fileName = encodeURIComponent(this.getFileName());
const fileUrl = encodeURIComponent(this.fileUrl);
const fileType = encodeURIComponent(this.getFileType());
return `/pages_tool/file-preview/file-preview?fileName=${fileName}&fileUrl=${fileUrl}&fileType=${fileType}`;
},
openFile() {
if (!this.fileUrl) {
this.failed = true;
this.$util.showToast({
title: this.$lang('files.list.fileNotExist'),
icon: 'none'
});
return;
}
this.failed = false;
const ext = this.getFileExt();
const previewPage = this.buildPreviewUrl();
// ========== H5 端: 用预览页替换当前页(web-view 走 iframe),失败则新标签页打开 ==========
// #ifdef H5
uni.redirectTo({
url: previewPage,
fail: () => {
try {
window.open(this.fileUrl, '_blank');
} catch (e) {
this.failed = true;
}
}
});
return;
// #endif
// ========== 非 H5 端: 下载文件后按平台分支打开 ==========
// #ifndef H5
uni.showLoading({ title: this.$lang('files.list.loading'), mask: true });
uni.downloadFile({
url: this.fileUrl,
success: res => {
if (res.statusCode !== 200) {
uni.hideLoading();
this.failed = true;
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
return;
}
// ----- 微信小程序: saveFile 重命名 + openDocument(标题取短文件名) -----
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager();
const title = (this.displayTitle || 'file').toString().replace(/[\\/:*?"<>|\r\n\t]/g, '_');
const targetPath = `${wx.env.USER_DATA_PATH}/fs_${Date.now()}_${title}.${ext}`;
const openByPath = (filePath, cleanup) => {
uni.openDocument({
filePath,
fileType: ext,
showMenu: true,
fail: () => {
this.failed = true;
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
},
complete: () => {
if (cleanup) {
try { fs.removeSavedFile({ filePath }); } catch (e) {}
}
}
});
};
try {
fs.saveFile({
tempFilePath: res.tempFilePath,
filePath: targetPath,
success: r => {
uni.hideLoading();
openByPath(r.savedFilePath, true);
},
fail: () => {
// saveFile 失败(配额等),直接用临时文件兜底
uni.hideLoading();
openByPath(res.tempFilePath, false);
}
});
} catch (e) {
uni.hideLoading();
openByPath(res.tempFilePath, false);
}
// #endif
// ----- App / 其他小程序: 直接 openDocument;失败兜底跳转预览页 -----
// #ifndef MP-WEIXIN
uni.hideLoading();
uni.openDocument({
filePath: res.tempFilePath,
fileType: ext,
showMenu: true,
fail: () => {
uni.navigateTo({
url: previewPage,
fail: () => {
this.failed = true;
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
}
});
}
});
// #endif
},
fail: () => {
uni.hideLoading();
this.failed = true;
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
},
complete: res => {
if (res && res.statusCode === 404) {
uni.hideLoading();
this.failed = true;
uni.showToast({ title: this.$lang('files.list.fileNotExist'), icon: 'none' });
}
}
});
// #endif
}
}
};
</script>
<style scoped>
.files-detail {
width: 100%;
min-height: 100vh;
padding: 30rpx;
box-sizing: border-box;
background-color: #f8f8f8;
}
.files-detail .file-card {
display: flex;
align-items: center;
padding: 30rpx;
background-color: #fff;
border-radius: 16rpx;
}
.files-detail .file-card .pdf-icon {
margin-right: 24rpx;
width: 96rpx;
height: 96rpx;
flex-shrink: 0;
background-color: #e64340;
border-radius: 10rpx;
position: relative;
overflow: hidden;
box-shadow: 0 2rpx 8rpx rgba(230, 67, 64, 0.2);
}
.files-detail .file-card .pdf-icon .pdf-icon-p {
position: absolute;
top: 2rpx;
left: 10rpx;
color: #ffffff;
font-size: 48rpx;
font-weight: 900;
font-family: Arial, Helvetica, sans-serif;
line-height: 1;
}
.files-detail .file-card .pdf-icon .pdf-icon-fold {
position: absolute;
top: 0;
right: 0;
width: 28rpx;
height: 28rpx;
background: linear-gradient(135deg, transparent 50%, #ffeceb 50%);
border-bottom-left-radius: 4rpx;
}
.files-detail .file-card .pdf-icon .pdf-icon-lines {
position: absolute;
right: 8rpx;
bottom: 8rpx;
width: 42rpx;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.files-detail .file-card .pdf-icon .pdf-icon-line {
width: 100%;
height: 3rpx;
background-color: #ffffff;
opacity: 0.85;
margin-bottom: 4rpx;
border-radius: 2rpx;
}
.files-detail .file-card .pdf-icon .pdf-icon-line:nth-child(2) {
width: 80%;
}
.files-detail .file-card .pdf-icon .pdf-icon-line:nth-child(3) {
width: 60%;
margin-bottom: 0;
}
.files-detail .file-card .info-wrap {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.files-detail .file-card .info-wrap .title {
font-weight: 700;
color: #303133;
font-size: 30rpx;
line-height: 1.4;
word-break: break-all;
}
.files-detail .file-card .info-wrap .read-wrap {
display: flex;
color: #999ca7;
align-items: center;
margin-top: 12rpx;
line-height: 1;
}
.files-detail .file-card .info-wrap .read-wrap text {
font-size: 24rpx;
}
.files-detail .action-area {
margin-top: 40rpx;
}
.files-detail .action-area .action-btn {
height: 84rpx;
line-height: 84rpx;
margin: 0;
padding: 0;
font-size: 30rpx;
border-radius: 42rpx;
background: linear-gradient(90deg, #ff7e47 0%, #f73131 100%);
color: #fff;
border: none;
font-weight: 600;
box-shadow: 0 4rpx 12rpx rgba(247, 49, 49, 0.25);
}
.files-detail .action-area .action-btn::after {
border: none;
}
.files-detail .error-tip {
margin-top: 30rpx;
text-align: center;
color: #999;
font-size: 26rpx;
line-height: 1.6;
}
</style>