Commit b7ec11bc authored by hwh's avatar hwh

APP提交

parent 38962fd6
/**
* 样地任务接口层(APP 端)
*
* 配套桌面端「样地监测」的任务下发:
* 桌面端点「下发至APP」→ 后端落库 t_onemap_sample_issue
* → APP 在这里拉任务清单 / 详情,采集完再上报。
*
* 底层复用 common/request.js:自动拼「全局接口地址」前缀、自动带登录令牌
* (Authorization: Bearer,由登录页的 /app/login 下发)。
*
* 返回约定:本层直接把后端 AjaxResult 的 data 取出来返回,
* 失败时抛错(由调用方 try/catch 决定提示方式)。
*
* ⚠ 后端无论成功失败 HTTP 状态码都是 200,业务结果要看 body.code ——
* 不能靠 HTTP 状态码判断成败。
*/
import { request } from './request.js'
import { clearToken, ISSUE_STATUS_ISSUED, ISSUE_STATUS_RETURNED } from './forest-data.js'
/** 未认证 / 令牌失效(见后端 HttpStatus.UNAUTHORIZED) */
const CODE_UNAUTHORIZED = 401
/**
* 令牌失效时清掉本地令牌并回登录页。
*
* <p>APP 令牌本身长期有效,但被后台强退、或令牌被清过时会返回 401,
* 此时停在页面里只会一直报错,必须引导重新登录。
*/
const handleUnauthorized = () => {
clearToken()
uni.showToast({ title: '登录已失效,请重新登录', icon: 'none' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/forest/login/login' })
}, 800)
}
/** 后端 AjaxResult:code=200 为成功,data 才是业务负载 */
const unwrap = res => {
const body = res && res.data
if (!body || typeof body !== 'object') {
throw new Error('接口返回格式异常')
}
if (body.code === CODE_UNAUTHORIZED) {
handleUnauthorized()
throw new Error('登录已失效')
}
if (body.code !== 200) {
throw new Error(body.msg || '请求失败')
}
return body.data
}
/**
* 拉取本 APP 的任务清单。
*
* @param {string} app APP 版本名(林草版 / 通用版 / 科研版)
* @param {string} status 后端下发状态:已下发(待采集)/ 已回传
*/
export const fetchTasks = async (app, status = ISSUE_STATUS_ISSUED) => {
const res = await request({
url: '/app/sample/tasks',
method: 'GET',
data: { app, status }
})
const data = unwrap(res)
return Array.isArray(data) ? data : []
}
/** 待采集清单 */
export const fetchPendingTasks = app => fetchTasks(app, ISSUE_STATUS_ISSUED)
/** 已回传清单 */
export const fetchReturnedTasks = app => fetchTasks(app, ISSUE_STATUS_RETURNED)
/** 拉取单块样地的任务详情(样地 + 样方明细) */
export const fetchTaskDetail = async sampleId => {
const res = await request({
url: `/app/sample/task/${sampleId}`,
method: 'GET'
})
return unwrap(res)
}
/**
* 上报采集数据(一键回传)。
*
* @param {object} payload { sampleId, app, records: [{ quadratIndex, payload }] }
* @returns {number} 实际写入的样方条数
*/
export const reportTask = async payload => {
const res = await request({
url: '/app/sample/report',
method: 'POST',
header: { 'Content-Type': 'application/json' },
data: payload
})
return unwrap(res)
}
/**
* 统一失败提示。
*
* 把「请求没能送达」与「业务失败」分开:前者多半是全局接口地址没配或不通,
* 直接抛后端 msg 会是一串英文异常;后者才用后端给的业务文案。
*/
export const toastError = error => {
const message = String((error && error.message) || '')
const isNetwork = !message || /timeout|request:fail|network/i.test(message)
uni.showToast({
title: isNetwork ? '网络请求失败,请检查全局接口地址' : message,
icon: 'none'
})
}
/**
* 退出登录:通知服务端删除令牌,并清掉本地令牌。
*
* 后端令牌不过期,不调用此接口的话服务端那条登录记录会一直有效 ——
* 所以即便请求失败也要清本地令牌(宁可要求重登,也不留一个已注销却仍可用的令牌)。
*/
export const logout = async () => {
try {
await request({ url: '/app/logout', method: 'POST' })
} catch (e) {
console.warn('[forest] 退出登录接口调用失败,仅清本地令牌', e)
}
clearToken()
}
/**
* 「我的」页顶部统计:待采集 / 已回传 / 已采集样方。
*
* 三个数都来自服务端:前两个是任务清单条数,第三个是本地草稿里已填的样方数
* (草稿还没回传,服务端自然没有,只能数本地)。
* 拉取失败时两个任务数回落为 0,不让统计接口异常把整页拖垮。
*/
export const fetchStats = async app => {
let pending = 0
let returned = 0
try {
const [pendingTasks, returnedTasks] = await Promise.all([
fetchTasks(app, ISSUE_STATUS_ISSUED),
fetchTasks(app, ISSUE_STATUS_RETURNED)
])
pending = pendingTasks.length
returned = returnedTasks.length
} catch (e) {
console.warn('[forest] 拉取任务统计失败', e)
}
return { pending, returned }
}
export default {
fetchTasks,
fetchPendingTasks,
fetchReturnedTasks,
fetchTaskDetail,
reportTask,
fetchStats,
logout
}
\ No newline at end of file
...@@ -7,6 +7,8 @@ ...@@ -7,6 +7,8 @@
* app_records 样方采集记录,键为 `${plotId}_${quadratIndex}` * app_records 样方采集记录,键为 `${plotId}_${quadratIndex}`
* forest_sample_issue 桌面端下发的样地数据 * forest_sample_issue 桌面端下发的样地数据
* app_version 当前选择的 APP 版本 * app_version 当前选择的 APP 版本
* api_base_url 全局接口地址前缀(后续 HTTP 请求统一拼接)
* app_token 登录令牌(/app/login 下发,供后续接口请求鉴权)
*/ */
const PLOTS_KEY = 'app_plots' const PLOTS_KEY = 'app_plots'
...@@ -15,14 +17,26 @@ const ISSUE_KEY = 'forest_sample_issue' ...@@ -15,14 +17,26 @@ const ISSUE_KEY = 'forest_sample_issue'
const VERSION_KEY = 'app_version' const VERSION_KEY = 'app_version'
// 采集端本地用户档案,与桌面端无数据契约,仅本机使用 // 采集端本地用户档案,与桌面端无数据契约,仅本机使用
const USER_KEY = 'app_user' const USER_KEY = 'app_user'
// 全局接口地址,仅本机使用,供 common/request.js 拼接请求前缀
const API_BASE_KEY = 'api_base_url'
// 登录令牌,仅本机使用;后端 /app/login 下发,令牌长期有效
const TOKEN_KEY = 'app_token'
export const APP_VERSIONS = ['林草版', '通用版', '科研版'] export const APP_VERSIONS = ['林草版', '通用版', '科研版']
/** 未在「设置」中保存过接口地址时使用的默认值 */
export const DEFAULT_API_BASE = 'http://127.0.0.1:7891'
export const ASPECTS = ['东', '南', '西', '北', '东北', '东南', '西北', '西南'] export const ASPECTS = ['东', '南', '西', '北', '东北', '东南', '西北', '西南']
export const STATUS_PENDING = '待采集' export const STATUS_PENDING = '待采集'
export const STATUS_RETURNED = '已回传' export const STATUS_RETURNED = '已回传'
/** 后端下发状态:已下发(= 本地「待采集」) */
export const ISSUE_STATUS_ISSUED = '已下发'
/** 后端下发状态:已回传 */
export const ISSUE_STATUS_RETURNED = '已回传'
const readJSON = (key, fallback) => { const readJSON = (key, fallback) => {
try { try {
const raw = uni.getStorageSync(key) const raw = uni.getStorageSync(key)
...@@ -153,6 +167,62 @@ export const receiveIssuedPlots = plots => { ...@@ -153,6 +167,62 @@ export const receiveIssuedPlots = plots => {
return { plots: merged, added } return { plots: merged, added }
} }
/**
* 服务端下发的任务 → 本地样地对象。
*
* 与 receiveIssuedPlots 的区别:任务来自后端接口(/app/sample/tasks),
* 因此能拿到 sampleId(服务端主键)与真实样方名 —— 回传时要靠 sampleId 定位服务端样地。
*
* ⚠ 本地 id 直接取 sampleId,不再另生成本地 id:
* 采集草稿是以 `本地id_样方序号` 为键落盘的,若每次拉列表都新生成一个 id,
* 刷新一次草稿就全成了对不上号的孤儿(详情页显示「未采集」但草稿还在库里)。
* sampleId 每次返回都一样,是天然的稳定键。
*/
export const plotFromTask = task => {
const quadrats = Array.isArray(task.quadrats) ? task.quadrats : []
const sampleId = task.sampleId ? String(task.sampleId) : ''
return {
id: sampleId || makePlotId(),
sampleId,
name: task.name,
area: Number(task.area) || 0,
quadrat: Number(task.quadrat) || quadrats.length,
// 样方名(服务端按 sort_order 排好序),详情页拿来显示比序号更直观
quadratNames: quadrats.map(item => item.name),
location: task.location || '',
coords: Array.isArray(task.coords) ? task.coords : [],
status: task.issueStatus === ISSUE_STATUS_RETURNED ? STATUS_RETURNED : STATUS_PENDING,
issuedAt: task.issuedAt || nowText(),
returnedAt: task.returnedAt || '',
// 已回传样地的已上报记录(详情/采集页只读回显用),待采集时为空数组
records: Array.isArray(task.records) ? task.records : []
}
}
/**
* 把接口拉到的任务并进本地列表。
*
* 去重优先用 sampleId(同一块样地重复下发只留一条),
* 没有 sampleId 时退化为按样地名去重 —— 与 receiveIssuedPlots 的口径一致。
*/
export const mergeTasks = (plots, tasks) => {
let added = 0
const merged = [...plots]
;(Array.isArray(tasks) ? tasks : []).forEach(task => {
if (!task || !task.name) return
const sampleId = task.sampleId ? String(task.sampleId) : ''
const exists = merged.some(item =>
sampleId ? item.sampleId === sampleId : item.name === task.name
)
if (exists) return
merged.unshift(plotFromTask(task))
added++
})
if (added > 0) savePlots(merged)
return { plots: merged, added }
}
/** 模拟接收一条平台下发的样地 */ /** 模拟接收一条平台下发的样地 */
export const createSimulatedPlot = plots => ({ export const createSimulatedPlot = plots => ({
id: makePlotId(), id: makePlotId(),
...@@ -183,8 +253,73 @@ export const saveVersion = version => { ...@@ -183,8 +253,73 @@ export const saveVersion = version => {
} }
} }
/* ---------------- 全局接口地址(仅采集端本地使用) ---------------- */
/**
* 规范化接口地址:去首尾空白、去掉末尾多余的 /
* 未写协议的主机名(如 192.168.1.10:8080)自动补 http://;
* 以 / 开头的同源相对前缀原样保留;输入为空时返回空串(表示清除)
*/
export const normalizeApiBase = value => {
const text = String(value === null || value === undefined ? '' : value).trim()
if (!text) return ''
const trimmed = text.replace(/\/+$/, '')
if (!trimmed.startsWith('/') && !/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
return `http://${trimmed}`
}
return trimmed
}
/**
* 读取全局接口地址:优先取「设置里保存过」的值,未保存过时使用默认地址
*/
export const loadApiBase = () => normalizeApiBase(uni.getStorageSync(API_BASE_KEY)) || DEFAULT_API_BASE
/**
* 保存全局接口地址;传入空值表示清除本地记录
* 返回实际保存的地址(清除时为空串)
*/
export const saveApiBase = value => {
const url = normalizeApiBase(value)
try {
if (url) {
uni.setStorageSync(API_BASE_KEY, url)
} else {
uni.removeStorageSync(API_BASE_KEY)
}
} catch (e) {
console.error('保存接口地址失败', e)
}
return url
}
export const formatArea = area => Number(area || 0).toLocaleString() export const formatArea = area => Number(area || 0).toLocaleString()
/* ---------------- 登录令牌(仅采集端本地使用) ---------------- */
/** 读取登录令牌,未登录时为空串 */
export const loadToken = () => {
const token = uni.getStorageSync(TOKEN_KEY)
return typeof token === 'string' ? token : ''
}
/** 保存登录令牌;传入空值表示清除(退出登录) */
export const saveToken = token => {
try {
if (token) {
uni.setStorageSync(TOKEN_KEY, token)
} else {
uni.removeStorageSync(TOKEN_KEY)
}
} catch (e) {
console.error('保存登录令牌失败', e)
}
return token || ''
}
/** 清除登录令牌 */
export const clearToken = () => saveToken('')
/* ---------------- 用户档案(仅采集端本地使用) ---------------- */ /* ---------------- 用户档案(仅采集端本地使用) ---------------- */
/** 未填写过档案时的演示信息,保证「用户信息」页始终有内容可展示 */ /** 未填写过档案时的演示信息,保证「用户信息」页始终有内容可展示 */
......
/**
* 统一 HTTP 请求封装
*
* 相对路径会自动拼接客户端保存的「全局接口地址」前缀
* (在登录页右上角「设置」中修改,读写见 common/forest-data.js)。
* 传入完整地址(http://、https://、//)时不做拼接,原样请求。
* 已登录时会自动附带登录令牌(Authorization: Bearer <token>)。
*
* 用法:
* import { request } from '@/common/request.js'
* const res = await request({ url: '/api/plots', method: 'GET' })
* // 实际请求 http://<已保存的前缀>/api/plots
*/
import { loadApiBase, loadToken } from './forest-data.js'
/** 是否已是完整地址(含协议或协议相对) */
const isAbsoluteUrl = url => /^([a-z][a-z0-9+.-]*:)?\/\//i.test(String(url || ''))
/**
* 拼接接口地址:相对路径自动加上「设置里保存的全局地址」前缀
* 未保存过时使用默认地址(见 forest-data.js 的 DEFAULT_API_BASE)
*/
export const resolveUrl = url => {
const target = String(url || '')
if (isAbsoluteUrl(target)) return target
const base = loadApiBase()
return base + (target.charAt(0) === '/' ? target : `/${target}`)
}
/**
* 发起请求,参数同 uni.request,返回 Promise
* 成功 resolve(res),失败 reject(err)
* 已登录时自动附带 Authorization 头;调用方显式传入 header 时以调用方为准
*/
export const request = options => {
const opts = options || {}
const token = loadToken()
const header = { ...(opts.header || {}) }
if (token && !header.Authorization) {
header.Authorization = `Bearer ${token}`
}
return new Promise((resolve, reject) => {
uni.request({
...opts,
header,
url: resolveUrl(opts.url),
success: resolve,
fail: reject
})
})
}
export default request
\ No newline at end of file
<template> <template>
<view class="plot-list"> <view class="plot-list">
<view v-if="filteredPlots.length === 0" class="empty">暂无{{ status }}数据</view> <view v-if="loading && filteredPlots.length === 0" class="empty">加载中...</view>
<view v-else-if="filteredPlots.length === 0" class="empty">暂无{{ statusLabel }}数据</view>
<view v-for="plot in filteredPlots" :key="plot.id" class="card" @click="goDetail(plot)"> <view v-for="plot in filteredPlots" :key="plot.id" class="card" @click="goDetail(plot)">
<view class="card__title"> <view class="card__title">
...@@ -11,11 +12,10 @@ ...@@ -11,11 +12,10 @@
</view> </view>
<view class="card__line">面积:{{ formatArea(plot.area) }} m²</view> <view class="card__line">面积:{{ formatArea(plot.area) }} m²</view>
<view class="card__line">样方数:{{ plot.quadrat }} 个</view> <view class="card__line">样方数:{{ plot.quadrat }} 个</view>
<view class="card__line" v-if="plot.location">位置:{{ plot.location }}</view>
<view class="card__line" v-if="plot.issuedAt">下发时间:{{ plot.issuedAt }}</view> <view class="card__line" v-if="plot.issuedAt">下发时间:{{ plot.issuedAt }}</view>
<view class="card__line" v-if="plot.returnedAt">回传时间:{{ plot.returnedAt }}</view> <view class="card__line" v-if="plot.returnedAt">回传时间:{{ plot.returnedAt }}</view>
</view> </view>
<button v-if="showSimulate" class="btn" @click="simulateReceive">模拟接收平台下发</button>
</view> </view>
</template> </template>
...@@ -23,18 +23,22 @@ ...@@ -23,18 +23,22 @@
import { import {
STATUS_PENDING, STATUS_PENDING,
STATUS_RETURNED, STATUS_RETURNED,
ISSUE_STATUS_ISSUED,
ISSUE_STATUS_RETURNED,
formatArea, formatArea,
loadPlots, plotFromTask
savePlots,
initDemoPlots,
receiveIssuedPlots,
createSimulatedPlot
} from '@/common/forest-data.js' } from '@/common/forest-data.js'
import { fetchTasks, toastError } from '@/common/forest-api.js'
/** /**
* 样地列表(待采集 / 已回传通用) * 样地列表(待采集 / 已回传通用)
* *
* 页面通过 ref 调用 refresh() / receiveIssued() 转发生命周期, * 数据来自后端 /app/sample/tasks,纯在线、不做本地缓存:
* 待采集页拉 status=已下发,已回传页拉 status=已回传,按 appVersion 各拉自己的任务。
* 回传后服务端会把下发记录置为「已回传」,该样地随之从待采集清单消失 ——
* 这正是「以服务端为准」的意义,本地缓存合并做不到这一点(会一直留在待采集里)。
*
* 页面通过 ref 调用 refresh() 转发生命周期,
* 因为组件内拿不到 onShow / onPullDownRefresh 等页面级钩子。 * 因为组件内拿不到 onShow / onPullDownRefresh 等页面级钩子。
*/ */
export default { export default {
...@@ -45,59 +49,62 @@ ...@@ -45,59 +49,62 @@
type: String, type: String,
default: STATUS_PENDING default: STATUS_PENDING
}, },
// 是否显示「模拟接收平台下发」按钮(仅待采集页需要) // 当前 APP 版本名,作为后端任务的过滤条件
showSimulate: { appVersion: {
type: Boolean, type: String,
default: false default: ''
},
// 挂载后是否自动检查桌面端下发的样地(仅待采集页需要)
autoReceive: {
type: Boolean,
default: false
} }
}, },
data() { data() {
return { return {
STATUS_RETURNED, STATUS_RETURNED,
loading: false,
plots: [] plots: []
} }
}, },
computed: { computed: {
filteredPlots() { filteredPlots() {
return this.plots.filter(plot => plot.status === this.status) return this.plots.filter(plot => plot.status === this.status)
},
/** 拉取时用的后端状态:本地「待采集」对应后端「已下发」 */
issueStatus() {
return this.status === STATUS_RETURNED ? ISSUE_STATUS_RETURNED : ISSUE_STATUS_ISSUED
},
statusLabel() {
return this.status === STATUS_RETURNED ? '已回传' : '待采集'
}
},
watch: {
// 切换 APP 版本后立刻按新版本重新拉取
appVersion() {
this.refresh()
} }
}, },
mounted() { mounted() {
// 组件自行完成首次加载:页面 onLoad 阶段 $refs 尚未就绪,交给这里最稳 // 组件自行完成首次加载:页面 onLoad 阶段 $refs 尚未就绪,交给这里最稳
this.refresh() this.refresh()
if (this.autoReceive) {
this.receiveIssued()
}
}, },
methods: { methods: {
formatArea, formatArea,
/** 重新读取样地列表;silent 为 true 时不植入演示数据 */ toastError,
refresh() { /** 按当前 APP 版本与页签状态拉取任务清单 */
this.plots = initDemoPlots(loadPlots()) async refresh() {
}, const app = this.appVersion
/** 接收桌面端下发的样地,fromPullDown 用于区分是否提示「暂无新数据」 */ if (!app) return
receiveIssued(fromPullDown) {
const issued = receiveIssuedPlots(this.plots) this.loading = true
this.plots = issued.plots try {
if (issued.added > 0) { const tasks = await fetchTasks(app, this.issueStatus)
uni.showToast({ title: `已接收平台下发的 ${issued.added} 条样地数据`, icon: 'none' }) this.plots = tasks.map(plotFromTask)
} else if (fromPullDown) { } catch (e) {
uni.showToast({ title: '暂无新的下发数据', icon: 'none' }) console.warn('[forest] 拉取任务清单失败', e)
toastError(e)
} finally {
this.loading = false
} }
}, },
goDetail(plot) { goDetail(plot) {
uni.navigateTo({ url: `/pages/forest/detail/detail?id=${plot.id}` }) uni.navigateTo({ url: `/pages/forest/detail/detail?id=${plot.id}` })
},
simulateReceive() {
const plot = createSimulatedPlot(this.plots)
this.plots.unshift(plot)
savePlots(this.plots)
uni.showToast({ title: '已接收平台下发的样地数据', icon: 'none' })
} }
} }
} }
...@@ -159,21 +166,4 @@ ...@@ -159,21 +166,4 @@
color: #666666; color: #666666;
font-size: 14px; font-size: 14px;
} }
</style>
.btn { \ No newline at end of file
display: block;
width: calc(100% - 24px);
margin: 24px 12px 0;
padding: 12px;
border: none;
border-radius: 6px;
font-size: 15px;
color: #ffffff;
background-color: #155724;
line-height: 1.4;
}
.btn::after {
border: none;
}
</style>
{ {
"leftWindow": { // 注意:已移除 hello-uniapp 示例的 leftWindow / topWindow 配置。
"path": "windows/left-window.vue", // windows/top-window.vue 的 $route 监听会把入口路由 "/" 当作示例首页,
"style": { // 强制 uni.redirectTo 到 /pages/component/view/view,导致登录页无法停留。
"width": "350px"
}
},
"topWindow": {
"path": "windows/top-window.vue",
"style": {
"height": "60px"
}
},
"pages": [ "pages": [
// pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages // pages数组中第一项表示应用启动页(H5 入口路由为 "/",别名指向本页),参考:https://uniapp.dcloud.io/collocation/pages
// 以下 4 个页面为野外数据采集 APP,leftWindow/topWindow 置 false 可在 PC 宽屏下隐藏 // 以下 6 个页面为野外数据采集 APP,首项 login/login 即应用启动页
// hello-uniapp 示例专用的侧边栏与顶栏(见 uni-h5 运行时 route.meta 判断)
{ {
"path": "pages/forest/login/login", "path": "pages/forest/login/login",
"style": { "style": {
......
...@@ -104,15 +104,17 @@ ...@@ -104,15 +104,17 @@
import { import {
ASPECTS, ASPECTS,
STATUS_RETURNED, STATUS_RETURNED,
loadPlots,
loadRecords, loadRecords,
saveRecords, saveRecords,
readRecord, readRecord,
writeRecord, writeRecord,
emptyForm,
emptySpecies, emptySpecies,
loadVersion, loadVersion,
saveVersion saveVersion,
plotFromTask
} from '@/common/forest-data.js' } from '@/common/forest-data.js'
import { fetchTaskDetail, toastError } from '@/common/forest-api.js'
import forestVersionPicker from '@/components/forest-version-picker/forest-version-picker.vue' import forestVersionPicker from '@/components/forest-version-picker/forest-version-picker.vue'
// 对应原 demo 的 v-model.number:空值保留空字符串,可解析的转为数字 // 对应原 demo 的 v-model.number:空值保留空字符串,可解析的转为数字
...@@ -164,13 +166,43 @@ ...@@ -164,13 +166,43 @@
this.quadrat = Number(query.quadrat) || 0 this.quadrat = Number(query.quadrat) || 0
this.appVersion = loadVersion() this.appVersion = loadVersion()
this.plot = loadPlots().find(item => item.id === this.plotId) || null this.loadTask()
if (!this.plot) return
this.form = readRecord(loadRecords(), this.plotId, this.quadrat)
uni.setNavigationBarTitle({ title: `${this.plot.name} 样方${this.quadrat}` })
}, },
methods: { methods: {
/**
* 拉取样地详情并按模式装载表单。
*
* 纯在线:样地信息以服务端为准,本机不再缓存样地列表(同 detail.vue)。
* 已回传的样地进只读回显:表单取服务端已上报的那份记录,
* 而不是本地草稿 —— 换台设备进来看已回传数据时本地根本没有草稿。
*/
async loadTask() {
if (!this.plotId) return
try {
const detail = await fetchTaskDetail(this.plotId)
if (!detail) return
this.plot = plotFromTask(detail)
uni.setNavigationBarTitle({ title: `${this.plot.name} 样方${this.quadrat}` })
} catch (e) {
console.warn('[forest] 拉取样地详情失败', e)
toastError(e)
return
}
// 已回传:以服务端记录回显(只读);待采集:读本地草稿继续录入
const serverRecord = this.readonly ? this.serverRecordOf(this.quadrat) : null
this.form = serverRecord || readRecord(loadRecords(), this.plotId, this.quadrat)
},
/** 服务端已上报记录里的该样方表单;没有则返回 null */
serverRecordOf(quadrat) {
const list = this.plot && this.plot.records
if (!Array.isArray(list)) return null
const found = list.find(item => Number(item.quadratIndex) === Number(quadrat))
if (!found || !found.payload) return null
// 先按空表单铺底再覆盖:历史记录未必带全字段(如 species),
// 缺字段会让模板里的 form.species.length 直接报错
return Object.assign(emptyForm(), JSON.parse(JSON.stringify(found.payload)))
},
onAspectChange(e) { onAspectChange(e) {
const index = Number(e.detail.value) const index = Number(e.detail.value)
this.form.aspect = index <= 0 ? '' : ASPECTS[index - 1] this.form.aspect = index <= 0 ? '' : ASPECTS[index - 1]
......
...@@ -33,6 +33,7 @@ ...@@ -33,6 +33,7 @@
<view v-for="index in quadratList" :key="index" class="quad-card" <view v-for="index in quadratList" :key="index" class="quad-card"
:class="{ 'quad-card--collected': isCollected(index) }" @click="goCollect(index)"> :class="{ 'quad-card--collected': isCollected(index) }" @click="goCollect(index)">
<view class="quad-card__num">{{ index }}</view> <view class="quad-card__num">{{ index }}</view>
<view class="quad-card__name" v-if="quadratName(index)">{{ quadratName(index) }}</view>
<view class="quad-card__status" :class="isCollected(index) ? 'quad-card__status--done' : ''"> <view class="quad-card__status" :class="isCollected(index) ? 'quad-card__status--done' : ''">
{{ isCollected(index) ? '已采集' : '未采集' }} {{ isCollected(index) ? '已采集' : '未采集' }}
</view> </view>
...@@ -55,16 +56,16 @@ ...@@ -55,16 +56,16 @@
import { import {
STATUS_RETURNED, STATUS_RETURNED,
formatArea, formatArea,
loadPlots,
savePlots,
loadRecords, loadRecords,
hasRecord, hasRecord,
collectedCount, collectedCount,
anyRecord, anyRecord,
markReturned, recordKey,
plotFromTask,
loadVersion, loadVersion,
saveVersion saveVersion
} from '@/common/forest-data.js' } from '@/common/forest-data.js'
import { fetchTaskDetail, reportTask, toastError } from '@/common/forest-api.js'
import forestVersionPicker from '@/components/forest-version-picker/forest-version-picker.vue' import forestVersionPicker from '@/components/forest-version-picker/forest-version-picker.vue'
export default { export default {
...@@ -108,17 +109,44 @@ ...@@ -108,17 +109,44 @@
}, },
methods: { methods: {
formatArea, formatArea,
refresh() { /**
const plots = loadPlots() * 拉取服务端详情(样地 + 样方明细 + 已上报记录)。
this.plot = plots.find(item => item.id === this.plotId) || null *
* plotId 就是 sampleId(见 plotFromTask),直接拿它请求。
* 纯在线:详情以服务端为准,本机不再缓存样地列表。
*/
async refresh() {
if (!this.plotId) return
this.records = loadRecords() this.records = loadRecords()
if (this.plot) { try {
const detail = await fetchTaskDetail(this.plotId)
if (!detail) {
this.plot = null
return
}
this.plot = plotFromTask(detail)
uni.setNavigationBarTitle({ title: this.plot.name }) uni.setNavigationBarTitle({ title: this.plot.name })
} catch (e) {
console.warn('[forest] 拉取样地详情失败', e)
toastError(e)
} }
}, },
isCollected(index) { isCollected(index) {
// 已回传样地:以服务端已上报记录为准;待采集:看本地草稿
if (this.isServerCollected(index)) return true
return this.plot ? hasRecord(this.records, this.plot.id, index) : false return this.plot ? hasRecord(this.records, this.plot.id, index) : false
}, },
/** 服务端记录里是否有该样方(已回传样地只读回显用) */
isServerCollected(index) {
const list = this.plot && this.plot.records
if (!Array.isArray(list)) return false
return list.some(item => Number(item.quadratIndex) === Number(index))
},
/** 服务端样方名(按 sort_order 排序,本地序号 N ↔ quadrats[N-1]);没有则返回空串沿用序号 */
quadratName(index) {
const names = this.plot && this.plot.quadratNames
return Array.isArray(names) ? names[index - 1] || '' : ''
},
goCollect(index) { goCollect(index) {
// 已回传的样地仍可进入,采集页会以只读模式展示 // 已回传的样地仍可进入,采集页会以只读模式展示
uni.navigateTo({ uni.navigateTo({
...@@ -134,16 +162,49 @@ ...@@ -134,16 +162,49 @@
title: '一键回传', title: '一键回传',
content: '确定要将该样地所有采集数据一键回传吗?', content: '确定要将该样地所有采集数据一键回传吗?',
success: res => { success: res => {
if (!res.confirm) return if (res.confirm) this.submitReturn()
const plots = loadPlots()
const index = plots.findIndex(item => item.id === this.plotId)
if (index < 0) return
plots[index] = markReturned(plots[index])
savePlots(plots)
this.plot = plots[index]
uni.showToast({ title: '回传成功', icon: 'none' })
} }
}) })
},
/**
* 真正回传:把该样地已采集的样方整份提交给服务端。
*
* 服务端成功才提示成功并返回列表页 —— 失败保留本地草稿可重试,
* 不会出现「界面显示已回传、服务器却没收到」的假成功。
*/
async submitReturn() {
const plot = this.plot
const total = Number(plot.quadrat) || 0
const records = loadRecords()
const items = []
for (let index = 1; index <= total; index++) {
const record = records[recordKey(plot.id, index)]
if (record) items.push({ quadratIndex: index, payload: record })
}
if (items.length === 0) {
uni.showToast({ title: '请先完成至少一个样方采集', icon: 'none' })
return
}
uni.showLoading({ title: '回传中' })
try {
await reportTask({
sampleId: plot.sampleId || plot.id,
app: this.appVersion,
records: items
})
} catch (e) {
uni.hideLoading()
console.warn('[forest] 回传失败', e)
toastError(e)
return
}
uni.hideLoading()
uni.showToast({ title: '回传成功', icon: 'none' })
// 返回列表页由它的 onShow 重新拉取 —— 该样地已在服务端变为「已回传」,
// 会从待采集清单消失、出现在已回传清单里
setTimeout(() => uni.navigateBack(), 600)
} }
} }
} }
...@@ -220,6 +281,16 @@ ...@@ -220,6 +281,16 @@
color: #155724; color: #155724;
} }
.quad-card__name {
font-size: 11px;
color: #666666;
margin-top: 2px;
/* 样方名可能较长,单行省略,避免撑破 1/3 宽的卡片 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quad-card__status { .quad-card__status {
font-size: 12px; font-size: 12px;
margin-top: 4px; margin-top: 4px;
......
<template> <template>
<view class="page"> <view class="page">
<forest-plot-list ref="plotList" :status="STATUS_PENDING" show-simulate auto-receive /> <forest-plot-list ref="plotList" :status="STATUS_PENDING" :app-version="appVersion" />
<view class="version-fab"> <view class="version-fab">
<forest-version-picker v-model="appVersion" /> <forest-version-picker v-model="appVersion" />
...@@ -38,15 +38,11 @@ ...@@ -38,15 +38,11 @@
}, },
onPullDownRefresh() { onPullDownRefresh() {
this.refresh() this.refresh()
this.receiveIssued(true)
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
}, },
methods: { methods: {
refresh() { refresh() {
if (this.$refs.plotList) this.$refs.plotList.refresh() if (this.$refs.plotList) this.$refs.plotList.refresh()
},
receiveIssued(fromPullDown) {
if (this.$refs.plotList) this.$refs.plotList.receiveIssued(fromPullDown)
} }
} }
} }
...@@ -64,4 +60,4 @@ ...@@ -64,4 +60,4 @@
right: 12px; right: 12px;
z-index: 20; z-index: 20;
} }
</style> </style>
\ No newline at end of file
<template> <template>
<view class="login-wrap"> <view class="login-wrap">
<!-- 右上角全局设置:配置后续 HTTP 请求的接口地址前缀。
页面用的是系统原生导航栏,无法在其上挂自定义按钮,故固定在内容区右上角 -->
<view class="settings-fab" @click="openApiSetting">
<text class="settings-fab__icon">⚙</text>
</view>
<view class="login-logo">🌲</view> <view class="login-logo">🌲</view>
<view class="login-title">野外数据采集</view> <view class="login-title">野外数据采集</view>
<view class="login-sub">APP 登录</view> <view class="login-sub">APP 登录</view>
...@@ -19,7 +25,7 @@ ...@@ -19,7 +25,7 @@
<text class="form-item__label">APP 版本</text> <text class="form-item__label">APP 版本</text>
<forest-version-picker v-model="appVersion" /> <forest-version-picker v-model="appVersion" />
</view> </view>
<button class="btn" @click="doLogin">登 录</button> <button class="btn" :loading="logging" @click="doLogin">登 录</button>
</view> </view>
<view class="version-fab"> <view class="version-fab">
...@@ -29,7 +35,8 @@ ...@@ -29,7 +35,8 @@
</template> </template>
<script> <script>
import { loadVersion, saveVersion, markLogin } from '@/common/forest-data.js' import { loadVersion, saveVersion, markLogin, loadApiBase, saveApiBase, saveToken, DEFAULT_API_BASE } from '@/common/forest-data.js'
import { request } from '@/common/request.js'
import forestVersionPicker from '@/components/forest-version-picker/forest-version-picker.vue' import forestVersionPicker from '@/components/forest-version-picker/forest-version-picker.vue'
export default { export default {
...@@ -40,7 +47,8 @@ ...@@ -40,7 +47,8 @@
return { return {
username: '', username: '',
password: '', password: '',
appVersion: '林草版' appVersion: '林草版',
logging: false
} }
}, },
watch: { watch: {
...@@ -52,16 +60,63 @@ ...@@ -52,16 +60,63 @@
this.appVersion = loadVersion() this.appVersion = loadVersion()
}, },
methods: { methods: {
doLogin() { // 右上角「设置」:编辑全局接口地址,保存在客户端本地
openApiSetting() {
uni.showModal({
title: '全局接口地址',
editable: true,
// editable 模式下 content 即输入框初始值,先回填当前地址
content: loadApiBase(),
placeholderText: '如 http://192.168.1.10:8080,留空恢复默认',
confirmText: '保存',
cancelText: '取消',
success: res => {
if (!res.confirm) return
// saveApiBase 返回空串表示已清除,读取时会回落到默认地址
const saved = saveApiBase(res.content)
uni.showToast({
title: saved ? `已保存:${saved}` : `已恢复默认:${DEFAULT_API_BASE}`,
icon: 'none'
})
}
})
},
// 走「全局地址 + /app/login」校验账号密码
async doLogin() {
if (!this.username.trim() || !this.password.trim()) { if (!this.username.trim() || !this.password.trim()) {
uni.showToast({ title: '请输入账号和密码', icon: 'none' }) uni.showToast({ title: '请输入账号和密码', icon: 'none' })
return return
} }
// 记录账号与登录时间,供「用户信息」页展示 if (this.logging) return
markLogin(this.username) this.logging = true
uni.showToast({ title: '登录成功', icon: 'none' }) try {
// 列表页已是 tabBar 页面,必须用 switchTab(redirectTo 对 tabBar 页面无效) const res = await request({
uni.switchTab({ url: '/pages/forest/list/list' }) url: '/app/login',
method: 'POST',
header: { 'Content-Type': 'application/json' },
data: {
username: this.username.trim(),
password: this.password
}
})
const body = res && res.data
if (!body || body.code !== 200 || !body.token) {
// 账号或密码错误:提示后留在当前页,不跳转
uni.showToast({ title: '账户错误', icon: 'none' })
return
}
saveToken(body.token)
// 记录账号与登录时间,供「用户信息」页展示
markLogin(this.username)
uni.showToast({ title: '登录成功', icon: 'none' })
// 列表页已是 tabBar 页面,必须用 switchTab(redirectTo 对 tabBar 页面无效)
uni.switchTab({ url: '/pages/forest/list/list' })
} catch (e) {
// 请求未能送达(未配置全局地址、地址不通等),与账号密码无关,单独提示
uni.showToast({ title: '网络请求失败,请检查全局接口地址', icon: 'none' })
} finally {
this.logging = false
}
} }
} }
} }
...@@ -79,6 +134,26 @@ ...@@ -79,6 +134,26 @@
background: linear-gradient(180deg, #155724 0%, #0b3d2c 40%, #f5f5f5 40%); background: linear-gradient(180deg, #155724 0%, #0b3d2c 40%, #f5f5f5 40%);
} }
.settings-fab {
position: absolute;
top: 12px;
right: 12px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.18);
z-index: 30;
}
.settings-fab__icon {
font-size: 20px;
line-height: 1;
color: #ffffff;
}
.login-logo { .login-logo {
font-size: 64px; font-size: 64px;
line-height: 1.2; line-height: 1.2;
......
...@@ -79,10 +79,11 @@ ...@@ -79,10 +79,11 @@
<script> <script>
import { import {
loadUser, loadUser,
plotStats, loadRecords,
loadVersion, loadVersion,
saveVersion saveVersion
} from '@/common/forest-data.js' } from '@/common/forest-data.js'
import { logout as apiLogout, fetchStats } from '@/common/forest-api.js'
import forestVersionPicker from '@/components/forest-version-picker/forest-version-picker.vue' import forestVersionPicker from '@/components/forest-version-picker/forest-version-picker.vue'
export default { export default {
...@@ -120,16 +121,31 @@ ...@@ -120,16 +121,31 @@
onShow() { onShow() {
// 从其他 tab 切回时刷新统计与档案 // 从其他 tab 切回时刷新统计与档案
this.user = loadUser() this.user = loadUser()
this.stats = plotStats() this.refreshStats()
}, },
methods: { methods: {
/**
* 刷新顶部统计。
*
* 待采集 / 已回传来自服务端任务清单(纯在线,本地不再缓存样地列表,
* 数本地库会恒为 0);已采集样方仍然只能数本地草稿 ——
* 草稿是还没回传到服务端的那部分。
*/
async refreshStats() {
const quadrats = () => Object.keys(loadRecords()).length
this.stats = { ...this.stats, quadrats: quadrats() }
const remote = await fetchStats(loadVersion())
this.stats = { ...remote, quadrats: quadrats() }
},
logout() { logout() {
uni.showModal({ uni.showModal({
title: '退出登录', title: '退出登录',
content: '确定要退出当前账号吗?', content: '确定要退出当前账号吗?',
confirmColor: '#ff4d4f', confirmColor: '#ff4d4f',
success: res => { success: async res => {
if (!res.confirm) return if (!res.confirm) return
// 通知服务端删除令牌 + 清本地令牌;失败也不阻塞跳转
await apiLogout()
uni.reLaunch({ url: '/pages/forest/login/login' }) uni.reLaunch({ url: '/pages/forest/login/login' })
} }
}) })
......
<template> <template>
<view class="page"> <view class="page">
<forest-plot-list ref="plotList" :status="STATUS_RETURNED" /> <forest-plot-list ref="plotList" :status="STATUS_RETURNED" :app-version="appVersion" />
<view class="version-fab"> <view class="version-fab">
<forest-version-picker v-model="appVersion" /> <forest-version-picker v-model="appVersion" />
...@@ -60,4 +60,4 @@ ...@@ -60,4 +60,4 @@
right: 12px; right: 12px;
z-index: 20; z-index: 20;
} }
</style> </style>
\ No newline at end of file
...@@ -57,27 +57,14 @@ ...@@ -57,27 +57,14 @@
} }
}, },
watch: { watch: {
// 只同步菜单高亮,绝不因路由变化自动跳转:
// 入口路由 "/" 属于本应用的登录页,不能再被当作示例首页重定向到 pages/component/view/view
$route: { $route: {
immediate: true, immediate: true,
handler(newRoute) { handler(newRoute) {
const width = uni.getSystemInfoSync().screenWidth const width = uni.getSystemInfoSync().screenWidth
if (width >= 768) { if (width >= 768) {
let path = newRoute.path this.current = this.selected[newRoute.path.split('/')[2]]
let comp
if (path === '/') {
comp = 'component'
path = '/pages/tabBar/component/component'
} else {
comp = path.split('/')[2]
}
this.current = this.selected[comp]
for (const item of this.indexPage) {
if (path === item.tabBar) {
uni.redirectTo({
url: item.index
})
}
}
} }
} }
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment