Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Sign in
Toggle navigation
F
forest-app
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Packages
Packages
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
hwh
forest-app
Commits
b7ec11bc
Commit
b7ec11bc
authored
Oct 09, 2026
by
hwh
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
APP提交
parent
38962fd6
Show whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
638 additions
and
130 deletions
+638
-130
forest-api.js
common/forest-api.js
+162
-0
forest-data.js
common/forest-data.js
+135
-0
request.js
common/request.js
+54
-0
forest-plot-list.vue
components/forest-plot-list/forest-plot-list.vue
+47
-57
pages.json
pages.json
+5
-15
collect.vue
pages/forest/collect/collect.vue
+39
-7
detail.vue
pages/forest/detail/detail.vue
+86
-15
list.vue
pages/forest/list/list.vue
+2
-6
login.vue
pages/forest/login/login.vue
+84
-9
profile.vue
pages/forest/profile/profile.vue
+19
-3
returned.vue
pages/forest/returned/returned.vue
+2
-2
top-window.vue
windows/top-window.vue
+3
-16
No files found.
common/forest-api.js
0 → 100644
View file @
b7ec11bc
/**
* 样地任务接口层(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
common/forest-data.js
View file @
b7ec11bc
...
...
@@ -7,6 +7,8 @@
* app_records 样方采集记录,键为 `${plotId}_${quadratIndex}`
* forest_sample_issue 桌面端下发的样地数据
* app_version 当前选择的 APP 版本
* api_base_url 全局接口地址前缀(后续 HTTP 请求统一拼接)
* app_token 登录令牌(/app/login 下发,供后续接口请求鉴权)
*/
const
PLOTS_KEY
=
'app_plots'
...
...
@@ -15,14 +17,26 @@ const ISSUE_KEY = 'forest_sample_issue'
const
VERSION_KEY
=
'app_version'
// 采集端本地用户档案,与桌面端无数据契约,仅本机使用
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
DEFAULT_API_BASE
=
'http://127.0.0.1:7891'
export
const
ASPECTS
=
[
'东'
,
'南'
,
'西'
,
'北'
,
'东北'
,
'东南'
,
'西北'
,
'西南'
]
export
const
STATUS_PENDING
=
'待采集'
export
const
STATUS_RETURNED
=
'已回传'
/** 后端下发状态:已下发(= 本地「待采集」) */
export
const
ISSUE_STATUS_ISSUED
=
'已下发'
/** 后端下发状态:已回传 */
export
const
ISSUE_STATUS_RETURNED
=
'已回传'
const
readJSON
=
(
key
,
fallback
)
=>
{
try
{
const
raw
=
uni
.
getStorageSync
(
key
)
...
...
@@ -153,6 +167,62 @@ export const receiveIssuedPlots = plots => {
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
=>
({
id
:
makePlotId
(),
...
...
@@ -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
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
(
''
)
/* ---------------- 用户档案(仅采集端本地使用) ---------------- */
/** 未填写过档案时的演示信息,保证「用户信息」页始终有内容可展示 */
...
...
common/request.js
0 → 100644
View file @
b7ec11bc
/**
* 统一 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
components/forest-plot-list/forest-plot-list.vue
View file @
b7ec11bc
<
template
>
<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
class=
"card__title"
>
...
...
@@ -11,11 +12,10 @@
</view>
<view
class=
"card__line"
>
面积:
{{
formatArea
(
plot
.
area
)
}}
m²
</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.returnedAt"
>
回传时间:
{{
plot
.
returnedAt
}}
</view>
</view>
<button
v-if=
"showSimulate"
class=
"btn"
@
click=
"simulateReceive"
>
模拟接收平台下发
</button>
</view>
</
template
>
...
...
@@ -23,18 +23,22 @@
import
{
STATUS_PENDING
,
STATUS_RETURNED
,
ISSUE_STATUS_ISSUED
,
ISSUE_STATUS_RETURNED
,
formatArea
,
loadPlots
,
savePlots
,
initDemoPlots
,
receiveIssuedPlots
,
createSimulatedPlot
plotFromTask
}
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 等页面级钩子。
*/
export
default
{
...
...
@@ -45,59 +49,62 @@
type
:
String
,
default
:
STATUS_PENDING
},
// 是否显示「模拟接收平台下发」按钮(仅待采集页需要)
showSimulate
:
{
type
:
Boolean
,
default
:
false
},
// 挂载后是否自动检查桌面端下发的样地(仅待采集页需要)
autoReceive
:
{
type
:
Boolean
,
default
:
false
// 当前 APP 版本名,作为后端任务的过滤条件
appVersion
:
{
type
:
String
,
default
:
''
}
},
data
()
{
return
{
STATUS_RETURNED
,
loading
:
false
,
plots
:
[]
}
},
computed
:
{
filteredPlots
()
{
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
()
{
// 组件自行完成首次加载:页面 onLoad 阶段 $refs 尚未就绪,交给这里最稳
this
.
refresh
()
if
(
this
.
autoReceive
)
{
this
.
receiveIssued
()
}
},
methods
:
{
formatArea
,
/** 重新读取样地列表;silent 为 true 时不植入演示数据 */
refresh
()
{
this
.
plots
=
initDemoPlots
(
loadPlots
())
},
/** 接收桌面端下发的样地,fromPullDown 用于区分是否提示「暂无新数据」 */
receiveIssued
(
fromPullDown
)
{
const
issued
=
receiveIssuedPlots
(
this
.
plots
)
this
.
plots
=
issued
.
plots
if
(
issued
.
added
>
0
)
{
uni
.
showToast
({
title
:
`已接收平台下发的
${
issued
.
added
}
条样地数据`
,
icon
:
'none'
})
}
else
if
(
fromPullDown
)
{
uni
.
showToast
({
title
:
'暂无新的下发数据'
,
icon
:
'none'
})
toastError
,
/** 按当前 APP 版本与页签状态拉取任务清单 */
async
refresh
()
{
const
app
=
this
.
appVersion
if
(
!
app
)
return
this
.
loading
=
true
try
{
const
tasks
=
await
fetchTasks
(
app
,
this
.
issueStatus
)
this
.
plots
=
tasks
.
map
(
plotFromTask
)
}
catch
(
e
)
{
console
.
warn
(
'[forest] 拉取任务清单失败'
,
e
)
toastError
(
e
)
}
finally
{
this
.
loading
=
false
}
},
goDetail
(
plot
)
{
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 @@
color
:
#666666
;
font-size
:
14px
;
}
.btn
{
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
>
\ No newline at end of file
pages.json
View file @
b7ec11bc
{
"leftWindow"
:
{
"path"
:
"windows/left-window.vue"
,
"style"
:
{
"width"
:
"350px"
}
},
"topWindow"
:
{
"path"
:
"windows/top-window.vue"
,
"style"
:
{
"height"
:
"60px"
}
},
//
注意:已移除
hello-uniapp
示例的
leftWindow
/
topWindow
配置。
//
windows/top-window.vue
的
$route
监听会把入口路由
"/"
当作示例首页,
//
强制
uni.redirectTo
到
/pages/component/view/view,导致登录页无法停留。
"pages"
:
[
//
pages数组中第一项表示应用启动页,参考:https
:
//uniapp.dcloud.io/collocation/pages
//
以下
4
个页面为野外数据采集
APP,leftWindow/topWindow
置
false
可在
PC
宽屏下隐藏
//
hello-uniapp
示例专用的侧边栏与顶栏(见
uni-h
5
运行时
route.meta
判断)
//
pages数组中第一项表示应用启动页(H
5
入口路由为
"/"
,别名指向本页),参考:https
:
//uniapp.dcloud.io/collocation/pages
//
以下
6
个页面为野外数据采集
APP,首项
login/login
即应用启动页
{
"path"
:
"pages/forest/login/login"
,
"style"
:
{
...
...
pages/forest/collect/collect.vue
View file @
b7ec11bc
...
...
@@ -104,15 +104,17 @@
import
{
ASPECTS
,
STATUS_RETURNED
,
loadPlots
,
loadRecords
,
saveRecords
,
readRecord
,
writeRecord
,
emptyForm
,
emptySpecies
,
loadVersion
,
saveVersion
saveVersion
,
plotFromTask
}
from
'@/common/forest-data.js'
import
{
fetchTaskDetail
,
toastError
}
from
'@/common/forest-api.js'
import
forestVersionPicker
from
'@/components/forest-version-picker/forest-version-picker.vue'
// 对应原 demo 的 v-model.number:空值保留空字符串,可解析的转为数字
...
...
@@ -164,13 +166,43 @@
this
.
quadrat
=
Number
(
query
.
quadrat
)
||
0
this
.
appVersion
=
loadVersion
()
this
.
plot
=
loadPlots
().
find
(
item
=>
item
.
id
===
this
.
plotId
)
||
null
if
(
!
this
.
plot
)
return
this
.
form
=
readRecord
(
loadRecords
(),
this
.
plotId
,
this
.
quadrat
)
uni
.
setNavigationBarTitle
({
title
:
`
${
this
.
plot
.
name
}
样方
${
this
.
quadrat
}
`
})
this
.
loadTask
()
},
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
)
{
const
index
=
Number
(
e
.
detail
.
value
)
this
.
form
.
aspect
=
index
<=
0
?
''
:
ASPECTS
[
index
-
1
]
...
...
pages/forest/detail/detail.vue
View file @
b7ec11bc
...
...
@@ -33,6 +33,7 @@
<view
v-for=
"index in quadratList"
:key=
"index"
class=
"quad-card"
:class=
"
{ 'quad-card--collected': isCollected(index) }" @click="goCollect(index)">
<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' : ''"
>
{{
isCollected
(
index
)
?
'已采集'
:
'未采集'
}}
</view>
...
...
@@ -55,16 +56,16 @@
import
{
STATUS_RETURNED
,
formatArea
,
loadPlots
,
savePlots
,
loadRecords
,
hasRecord
,
collectedCount
,
anyRecord
,
markReturned
,
recordKey
,
plotFromTask
,
loadVersion
,
saveVersion
}
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'
export
default
{
...
...
@@ -108,17 +109,44 @@
},
methods
:
{
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
()
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
})
}
catch
(
e
)
{
console
.
warn
(
'[forest] 拉取样地详情失败'
,
e
)
toastError
(
e
)
}
},
isCollected
(
index
)
{
// 已回传样地:以服务端已上报记录为准;待采集:看本地草稿
if
(
this
.
isServerCollected
(
index
))
return
true
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
)
{
// 已回传的样地仍可进入,采集页会以只读模式展示
uni
.
navigateTo
({
...
...
@@ -134,16 +162,49 @@
title
:
'一键回传'
,
content
:
'确定要将该样地所有采集数据一键回传吗?'
,
success
:
res
=>
{
if
(
!
res
.
confirm
)
return
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'
})
if
(
res
.
confirm
)
this
.
submitReturn
()
}
})
},
/**
* 真正回传:把该样地已采集的样方整份提交给服务端。
*
* 服务端成功才提示成功并返回列表页 —— 失败保留本地草稿可重试,
* 不会出现「界面显示已回传、服务器却没收到」的假成功。
*/
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 @@
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
{
font-size
:
12px
;
margin-top
:
4px
;
...
...
pages/forest/list/list.vue
View file @
b7ec11bc
<
template
>
<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"
>
<forest-version-picker
v-model=
"appVersion"
/>
...
...
@@ -38,15 +38,11 @@
},
onPullDownRefresh
()
{
this
.
refresh
()
this
.
receiveIssued
(
true
)
uni
.
stopPullDownRefresh
()
},
methods
:
{
refresh
()
{
if
(
this
.
$refs
.
plotList
)
this
.
$refs
.
plotList
.
refresh
()
},
receiveIssued
(
fromPullDown
)
{
if
(
this
.
$refs
.
plotList
)
this
.
$refs
.
plotList
.
receiveIssued
(
fromPullDown
)
}
}
}
...
...
pages/forest/login/login.vue
View file @
b7ec11bc
<
template
>
<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-title"
>
野外数据采集
</view>
<view
class=
"login-sub"
>
APP 登录
</view>
...
...
@@ -19,7 +25,7 @@
<text
class=
"form-item__label"
>
APP 版本
</text>
<forest-version-picker
v-model=
"appVersion"
/>
</view>
<button
class=
"btn"
@
click=
"doLogin"
>
登 录
</button>
<button
class=
"btn"
:loading=
"logging"
@
click=
"doLogin"
>
登 录
</button>
</view>
<view
class=
"version-fab"
>
...
...
@@ -29,7 +35,8 @@
</
template
>
<
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'
export
default
{
...
...
@@ -40,7 +47,8 @@
return
{
username
:
''
,
password
:
''
,
appVersion
:
'林草版'
appVersion
:
'林草版'
,
logging
:
false
}
},
watch
:
{
...
...
@@ -52,16 +60,63 @@
this
.
appVersion
=
loadVersion
()
},
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
())
{
uni
.
showToast
({
title
:
'请输入账号和密码'
,
icon
:
'none'
})
return
}
if
(
this
.
logging
)
return
this
.
logging
=
true
try
{
const
res
=
await
request
({
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 @@
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
{
font-size
:
64px
;
line-height
:
1.2
;
...
...
pages/forest/profile/profile.vue
View file @
b7ec11bc
...
...
@@ -79,10 +79,11 @@
<
script
>
import
{
loadUser
,
plotStat
s
,
loadRecord
s
,
loadVersion
,
saveVersion
}
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'
export
default
{
...
...
@@ -120,16 +121,31 @@
onShow
()
{
// 从其他 tab 切回时刷新统计与档案
this
.
user
=
loadUser
()
this
.
stats
=
plot
Stats
()
this
.
refresh
Stats
()
},
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
()
{
uni
.
showModal
({
title
:
'退出登录'
,
content
:
'确定要退出当前账号吗?'
,
confirmColor
:
'#ff4d4f'
,
success
:
res
=>
{
success
:
async
res
=>
{
if
(
!
res
.
confirm
)
return
// 通知服务端删除令牌 + 清本地令牌;失败也不阻塞跳转
await
apiLogout
()
uni
.
reLaunch
({
url
:
'/pages/forest/login/login'
})
}
})
...
...
pages/forest/returned/returned.vue
View file @
b7ec11bc
<
template
>
<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"
>
<forest-version-picker
v-model=
"appVersion"
/>
...
...
windows/top-window.vue
View file @
b7ec11bc
...
...
@@ -57,27 +57,14 @@
}
},
watch
:
{
// 只同步菜单高亮,绝不因路由变化自动跳转:
// 入口路由 "/" 属于本应用的登录页,不能再被当作示例首页重定向到 pages/component/view/view
$route
:
{
immediate
:
true
,
handler
(
newRoute
)
{
const
width
=
uni
.
getSystemInfoSync
().
screenWidth
if
(
width
>=
768
)
{
let
path
=
newRoute
.
path
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
})
}
}
this
.
current
=
this
.
selected
[
newRoute
.
path
.
split
(
'/'
)[
2
]]
}
}
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment