This commit is contained in:
2026-08-25 17:32:38 +08:00
parent 73ac7b85db
commit 430ac6fd20
15 changed files with 75 additions and 57 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# observer
野生动物实时识别 AppFlutter 版)。Android / iOS 一套代码,后端接口与支付见
动物实时识别 AppFlutter 版)。Android / iOS 一套代码,后端接口与支付见
[`docs/PaymentApi.md`](docs/PaymentApi.md)。
## iOS 真机部署(iPhone
+1 -1
View File
@@ -59,7 +59,7 @@
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>需要使用相机进行野生动物实时识别</string>
<string>需要使用相机进行动物实时识别</string>
<key>NSLocalNetworkUsageDescription</key>
<string>需要通过本地网络连接服务器进行账号验证和支付</string>
<key>UIApplicationSceneManifest</key>
+1 -1
View File
@@ -67,7 +67,7 @@ class _AuthScreenState extends State<AuthScreen> {
),
const SizedBox(height: 4),
Text(
'野生动物实时识别',
'动物实时识别',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
+5 -5
View File
@@ -6,7 +6,7 @@ import 'package:provider/provider.dart';
import '../auth/session_store.dart';
/// 使用协议页:首次启动强制展示,同意后记录(secure storage),拒绝则退出应用。
/// 用途声明:仅限野生动物观察,禁止近距离打扰与非法使用。
/// 用途声明:仅限动物观察,禁止近距离打扰与非法使用。
class TermsScreen extends StatefulWidget {
const TermsScreen({super.key});
@@ -28,12 +28,12 @@ class _TermsScreenState extends State<TermsScreen> {
感谢您使用「视野」动物观察应用。使用本应用前,请仔细阅读并同意以下条款:
一、用途限制
本应用仅用于野生动物观察、记录与科普学习,严禁用于任何非法用途。
本应用仅用于动物观察、记录与科普学习,严禁用于任何非法用途。
二、禁止行为
1. 禁止近距离打扰、追逐、驱赶、恐吓野生动物;
2. 禁止投喂、诱捕、猎捕、伤害野生动物;
3. 禁止利用本应用从事盗猎、野生动物交易或其他违法犯罪活动;
1. 禁止近距离打扰、追逐、驱赶、恐吓动物;
2. 禁止投喂、诱捕、猎捕、伤害动物;
3. 禁止利用本应用从事盗猎、动物交易或其他违法犯罪活动;
4. 禁止进入自然保护区禁区、私人领地等未经许可的区域进行观察。
三、法律合规
+2 -2
View File
@@ -1,8 +1,8 @@
name: observer
description: "视野 - 野生动物实时识别 (野鸡/生境), YOLOv8 + 充值付费"
description: "视野 - 动物实时识别 (野鸡/生境), YOLOv8 + 充值付费"
publish_to: 'none'
version: 1.0.0+1
version: 1.0.2+3
environment:
sdk: ^3.12.2
+2 -2
View File
@@ -13,7 +13,7 @@
| 授权查询 | `GET /api/v1/license`Bearer token)返回授权状态,App 识别入口强制服务端校验 |
| 套餐 | `config.yml` `plans` 节点配置三档套餐(改价 = 改配置重启),价格**整数分**1000 / 5600 / 18000 |
| 后台管理端 | `server_admin/`Vue3 + Element Plus)管理页面:订单查询、账号/授权管理(手动授权/撤销)、App 版本管理;构建产物由后端 `/admin/` 托管,登录页输入 token 后以 `X-Admin-Token` 头鉴权(`config.yml admin.token` |
| 版本管理 | 后台管理端上传 Android APK + 版本号/更新说明,APK 存服务器 `app.apkDir`(默认 `./workspace/`,与 `./data` 平级、挂载持久化)**固定文件名 `observer-latest.apk`,上传即覆盖,目录永远只保留最新一个文件**;客户端启动时 `GET /api/v1/app/update` 检查更新:服务器版本高于本地版本即弹更新提示(不可跳过)。**仅 Android 检查,iOS 不做版本下发**iOS 走 App Store 自行更新)。版本记录可删除:删最新版本联动删除 APK 文件,删历史版本仅删记录 |
| 版本管理 | 后台管理端上传 Android APK + 更新说明,APK 存服务器 `app.apkDir`(默认 `./workspace/`,与 `./data` 平级、挂载持久化)**固定文件名 `observer-latest.apk`,上传即覆盖,目录永远只保留最新一个文件**;**版本号从文件名识别**:文件须命名为 `observer-x.y.z.apk`(Flutter 打包产物即此命名,版本号取自 pubspec);客户端启动时 `GET /api/v1/app/update` 检查更新:服务器版本高于本地版本即弹更新提示(不可跳过)。**仅 Android 检查,iOS 不做版本下发**iOS 走 App Store 自行更新)。版本记录可删除:删最新版本联动删除 APK 文件,删历史版本仅删记录 |
## 架构与数据流
@@ -161,7 +161,7 @@ App 版本更新检查(公开接口,无需 token,未登录/旧版本均可
| POST | `/admin/licenses/revoke` | 撤销授权 `{"phoneNum":"13800000000"}`(清授权保留账号) |
| POST | `/admin/licenses/remark` | 写账号备注 `{"phoneNum":"13800000000","remark":"..."}`(空串清空,≤200 字) |
| GET | `/admin/app-versions` | 版本记录列表,`page/size` 分页,按下发时间倒序 |
| POST | `/admin/app-versions` | 下发新版本(multipart/form-data):`version` + `notes` + `file`APK 文件,仅接受 `.apk`);版本号 x.y.z 必填且不可重复,APK 上传覆盖 `app.apkDir`/`observer-latest.apk`(目录永远只有一个文件);检测到新版本即强制更新 |
| POST | `/admin/app-versions` | 下发新版本(multipart/form-data):`notes` + `file`APK 文件,仅接受 `.apk`);**版本号从文件名识别**,文件须命名为 `observer-x.y.z.apk`(如 `observer-1.0.1.apk`),格式不符拒绝;版本号不可重复,APK 上传覆盖 `app.apkDir`/`observer-latest.apk`(目录永远只有一个文件);检测到新版本即强制更新 |
| POST | `/admin/app-versions/delete` | 删除版本记录 `{"id":1}`:删**最新版本**时联动删除 APK 文件(客户端不再提示更新、下载 404);删历史版本只删记录不动文件 |
管理页面(订单/授权)由 `server_admin/` 构建产物提供,访问 `http://<host>/admin/`。金额均为整数分,前端展示 ÷100 转元。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>视野管理端</title>
<script type="module" crossorigin src="/admin/assets/index-BS5pj_Bg.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-CesX8tXX.css">
<script type="module" crossorigin src="/admin/assets/index-BhTaosMq.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-COh41tJN.css">
</head>
<body>
<div id="app"></div>
+6 -7
View File
@@ -113,14 +113,13 @@ type AdminAppVersionListRes struct {
List []*AdminAppVersionItem `json:"list"`
}
// AdminAppVersionAddReq 下发新版本(multipart/form-dataversion + notes + APK 文件;
// 仅新增不删除,version UNIQUE 防重复下发;APK 覆盖保存固定文件,目录永远只有一个文件
// 检测到新版本即强制更新,无普通/强制之分)
// AdminAppVersionAddReq 下发新版本(multipart/form-datanotes + APK 文件;
// 版本号从文件名识别,文件须命名为 observer-x.y.z.apkversion UNIQUE 防重复下发
// APK 覆盖保存固定文件,目录永远只有一个文件;检测到新版本即强制更新,无普通/强制之分)
type AdminAppVersionAddReq struct {
g.Meta `path:"/app-versions" method:"post" summary:"下发新版本" tags:"管理端" mime:"multipart/form-data"`
Version string `json:"version" v:"required|regex:^\\d+\\.\\d+\\.\\d+$" dc:"语义化版本号 x.y.z"`
Notes string `json:"notes" v:"length:0,500" dc:"更新说明"`
File *ghttp.UploadFile `json:"file" dc:"APK 文件"`
g.Meta `path:"/app-versions" method:"post" summary:"下发新版本" tags:"管理端" mime:"multipart/form-data"`
Notes string `json:"notes" v:"length:0,500" dc:"更新说明"`
File *ghttp.UploadFile `json:"file" dc:"APK 文件(文件名须为 observer-x.y.z.apk"`
}
type AdminAppVersionAddRes struct{}
+25 -8
View File
@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"regexp"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
@@ -20,6 +21,9 @@ import (
// APK 存固定文件名(覆盖式,目录永远只有一个文件),见 common/apk_store.go。
type appVersionService struct{}
// apkNameRe APK 文件名规范:observer-x.y.z.apk(版本号从文件名识别)
var apkNameRe = regexp.MustCompile(`^observer-(\d+\.\d+\.\d+)\.apk$`)
var AppVersion = &appVersionService{}
// GetUpdate 客户端版本更新检查(公开接口,无需登录态;仅 Android 调用):
@@ -57,18 +61,20 @@ func (s *appVersionService) AdminListVersions(ctx context.Context, req *dto.Admi
return &dto.AdminAppVersionListRes{Total: total, List: items}, nil
}
// AdminAddVersion 下发新版本:先单写者串行落库(查重 + UNIQUE 兜底),再保存 APK
// 覆盖固定文件;文件保存失败时删除记录补偿,保证「记录存在 ⟺ 文件存在」。
// AdminAddVersion 下发新版本:版本号从文件名识别(observer-x.y.z.apk),
// 先单写者串行落库(查重 + UNIQUE 兜底),再保存 APK 覆盖固定文件;
// 文件保存失败时删除记录补偿,保证「记录存在 ⟺ 文件存在」。
func (s *appVersionService) AdminAddVersion(ctx context.Context, req *dto.AdminAppVersionAddReq) (*dto.AdminAppVersionAddRes, error) {
if req.File == nil {
return nil, gerror.NewCode(common.CodeApkInvalid)
}
if filepath.Ext(req.File.Filename) != ".apk" {
return nil, gerror.NewCode(common.CodeApkInvalid, "仅支持 .apk 文件")
version, err := parseVersionFromFilename(req.File.Filename)
if err != nil {
return nil, err
}
now := gtime.Now()
err := common.Serial().Submit(ctx, func() error {
exists, err := dao.AppVersion.GetByVersion(ctx, req.Version)
err = common.Serial().Submit(ctx, func() error {
exists, err := dao.AppVersion.GetByVersion(ctx, version)
if err != nil {
return err
}
@@ -76,7 +82,7 @@ func (s *appVersionService) AdminAddVersion(ctx context.Context, req *dto.AdminA
return gerror.NewCode(common.CodeVersionDuplicate)
}
return dao.AppVersion.Insert(ctx, &entity.AppVersion{
Version: req.Version,
Version: version,
Notes: req.Notes,
CreatedAt: now,
UpdatedAt: now,
@@ -86,7 +92,7 @@ func (s *appVersionService) AdminAddVersion(ctx context.Context, req *dto.AdminA
return nil, err
}
if err := saveApk(ctx, req.File); err != nil {
if delErr := dao.AppVersion.DeleteByVersion(ctx, req.Version); delErr != nil {
if delErr := dao.AppVersion.DeleteByVersion(ctx, version); delErr != nil {
g.Log().Errorf(ctx, "APK 保存失败后补偿删除记录失败: %+v", delErr)
}
return nil, err
@@ -94,6 +100,17 @@ func (s *appVersionService) AdminAddVersion(ctx context.Context, req *dto.AdminA
return &dto.AdminAppVersionAddRes{}, nil
}
// parseVersionFromFilename 从 APK 文件名识别版本号:observer-1.0.1.apk → 1.0.1。
// 文件名是上传版本号的唯一事实来源(打包产物即按此命名)。
func parseVersionFromFilename(name string) (string, error) {
m := apkNameRe.FindStringSubmatch(filepath.Base(name))
if m == nil {
return "", gerror.NewCode(common.CodeApkInvalid,
"APK 文件名须为 observer-x.y.z.apk 格式(如 observer-1.0.1.apk")
}
return m[1], nil
}
// AdminDeleteVersion 删除版本记录(Serial 串行,防与下发并发交错):
// 删最新版本时联动删除 APK 文件(客户端 update 返回空不再提示、下载 404);
// 删历史版本仅删记录、不动文件(固定文件永远对应最新版本)。
+15 -14
View File
@@ -65,7 +65,7 @@ func countApk(t *testing.T) int {
func TestAppVersionUpdate(t *testing.T) {
ver := uniqueVersion()
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
Version: ver, Notes: "修复识别准确率", File: newApkUpload(t, "v1.apk", "apk-v1"),
Notes: "修复识别准确率", File: newApkUpload(t, "observer-"+ver+".apk", "apk-v1"),
}); err != nil {
t.Fatalf("add version: %v", err)
}
@@ -90,8 +90,8 @@ func TestAppVersionUpdate(t *testing.T) {
func TestAppVersionOverwrite(t *testing.T) {
ver1 := uniqueVersion()
ver2 := uniqueVersion() + "1"
req1 := &dto.AdminAppVersionAddReq{Version: ver1, File: newApkUpload(t, "a.apk", "apk-old")}
req2 := &dto.AdminAppVersionAddReq{Version: ver2, File: newApkUpload(t, "b.apk", "apk-new")}
req1 := &dto.AdminAppVersionAddReq{File: newApkUpload(t, "observer-"+ver1+".apk", "apk-old")}
req2 := &dto.AdminAppVersionAddReq{File: newApkUpload(t, "observer-"+ver2+".apk", "apk-new")}
if _, err := AppVersion.AdminAddVersion(ctx(), req1); err != nil {
t.Fatalf("add v1: %v", err)
}
@@ -107,23 +107,24 @@ func TestAppVersionOverwrite(t *testing.T) {
}
}
// TestAppVersionInvalidFile 无文件 / 非 .apk 文件拒绝
// TestAppVersionInvalidFile 无文件 / 文件名不含版本号(格式不符)拒绝
func TestAppVersionInvalidFile(t *testing.T) {
ver := uniqueVersion()
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{Version: ver}); err == nil {
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{}); err == nil {
t.Fatal("missing file should fail")
}
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
Version: ver, File: newApkUpload(t, "app.txt", "not an apk"),
}); err == nil {
t.Fatal("non-apk file should fail")
for _, name := range []string{"app.txt", "myapp-1.0.apk", "observer-1.0.apk", "observer-abc.apk"} {
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
File: newApkUpload(t, name, "not an apk"),
}); err == nil {
t.Fatalf("file %s should fail (version not parseable)", name)
}
}
}
// TestAppVersionDuplicate 同版本号重复下发报错
func TestAppVersionDuplicate(t *testing.T) {
ver := uniqueVersion()
req := &dto.AdminAppVersionAddReq{Version: ver, File: newApkUpload(t, "v.apk", "apk")}
req := &dto.AdminAppVersionAddReq{File: newApkUpload(t, "observer-"+ver+".apk", "apk")}
if _, err := AppVersion.AdminAddVersion(ctx(), req); err != nil {
t.Fatalf("add version: %v", err)
}
@@ -137,7 +138,7 @@ func TestAppVersionList(t *testing.T) {
base := uniqueVersion()
for _, v := range []string{base, base + "1"} {
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
Version: v, File: newApkUpload(t, "v.apk", "apk"),
File: newApkUpload(t, "observer-"+v+".apk", "apk"),
}); err != nil {
t.Fatalf("add version %s: %v", v, err)
}
@@ -159,7 +160,7 @@ func TestAppVersionList(t *testing.T) {
func TestAppVersionDeleteLatest(t *testing.T) {
ver := uniqueVersion()
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
Version: ver, File: newApkUpload(t, "v.apk", "apk-v1"),
File: newApkUpload(t, "observer-"+ver+".apk", "apk-v1"),
}); err != nil {
t.Fatalf("add version: %v", err)
}
@@ -194,7 +195,7 @@ func TestAppVersionDeleteHistoric(t *testing.T) {
ver2 := uniqueVersion() + "1"
for _, v := range []string{ver1, ver2} {
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
Version: v, File: newApkUpload(t, "v.apk", "apk"),
File: newApkUpload(t, "observer-"+v+".apk", "apk"),
}); err != nil {
t.Fatalf("add version %s: %v", v, err)
}
Binary file not shown.
+5 -4
View File
@@ -166,7 +166,7 @@ CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num);
| POST | /licenses/grant | 手动授权 `{phoneNum, planId}`:与支付回调同语义(自然日叠加、单写者串行事务),提交后清授权缓存 |
| POST | /licenses/revoke | 撤销授权 `{phoneNum}`:清空 `expires_at` **保留账号行**(不删密码),清缓存,客户端下次查询即 inactive |
| GET | /app-versions | 版本记录列表:分页(size 上限 100),按下发时间倒序 |
| POST | /app-versions | 下发新版本(multipart/form-data`version` + `notes` + `file` APK):`version` 必填 x.y.z 且 UNIQUE 不可重复(重复报错)、`notes` ≤500、仅接受 `.apk` 文件;APK 覆盖保存 `app.apkDir`/`observer-latest.apk`(目录永远只有一个文件) |
| POST | /app-versions | 下发新版本(multipart/form-data`notes` + `file` APK):**版本号从文件名识别**,文件名须为 `observer-x.y.z.apk`(如 `observer-1.0.1.apk`,正则 `^observer-(\d+\.\d+\.\d+)\.apk$`,格式不符拒绝)、`notes` ≤500、仅接受 `.apk` 文件;APK 覆盖保存 `app.apkDir`/`observer-latest.apk`(目录永远只有一个文件) |
| POST | /app-versions/delete | 删除版本 `{id}`(不存在报错):删**最新版本**时联动删除 APK 文件(客户端 update 返回空不再提示、下载 404);删历史版本仅删记录、不动文件 |
**结构决策**:管理端是跨表业务面,controller 聚合在 `biz/controller/admin.go`(避免同一 controller 挂客户端组 + 管理组时 `group.Bind` 重复注册路由),service 按「跨表业务流程归入所属表文件」归入各表文件(订单列表 → `service/order.go`,授权 grant/revoke/列表 → `service/license.go`),dto 聚合在 `biz/model/dto/admin.go`。套餐已配置化(`common/plans.go` 读取 config.yml,仅客户端登录组 `GET /plans` 使用),无独立分层文件,管理端不管理套餐。
@@ -182,8 +182,8 @@ CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num);
**数据流**
```
管理端 POST /admin/app-versionsmultipartversion + notes + APK 文件)
├─► app_version 表新增记录(version UNIQUE 防重复下发)
管理端 POST /admin/app-versionsmultipartnotes + APK 文件,文件名 observer-x.y.z.apk 识别版本号
├─► app_version 表新增记录(version 从文件名解析,UNIQUE 防重复下发)
└─► APK 保存为 app.apkDir/observer-latest.apk(上传即覆盖,目录永远只有一个文件)
Android 客户端启动 GET /api/v1/app/update(公开,无需 tokeniOS 不调用)
└─► 返回最新一条记录(无记录返回空对象)
@@ -202,7 +202,8 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 tokeniOS 不
- **检测到新版本即强制更新**(无普通/强制之分):`version > 本地版本` 即全屏阻塞弹窗,用户必须跳转下载安装才能继续使用。简化管理端操作(不需要判断「这次要不要强制」),客户端语义单一(「有新版本 = 必须更新」)
- **公开接口**:更新检查挂在公开组(无需登录态)——旧版本登录态可能已失效,且登录前的用户也要能收到强制更新
- **iOS 不检查**:客户端 `UpdateChecker.fetch()` 在非 Android 平台直接返回空(iOS 用户从 App Store 更新,应用内无法安装 APK,提示无意义)
- **版本号语义化比较**`x.y.z` 三段按数字比较(`1.10.0 > 1.9.9`),禁止字符串比较("1.9.9" > "1.10.0" 会漏判);版本号格式由 DTO `regex` 校验DB 层 `UNIQUE` 兜底防重复
- **版本号语义化比较**`x.y.z` 三段按数字比较(`1.10.0 > 1.9.9`),禁止字符串比较("1.9.9" > "1.10.0" 会漏判);版本号格式由文件名解析正则校验(`observer-x.y.z.apk`DB 层 `UNIQUE` 兜底防重复
- **文件名识别版本号**:管理端上传不再手工填写版本号,版本号从文件名解析(打包产物即 `observer-<versionName>.apk` 命名)——消除「填的版本号与包内版本不一致」的人为错误,上传文件名即唯一事实来源
- **删除联动文件**:APK 固定文件永远对应「最新一条记录」,故删除只对最新版本联动删文件(`记录存在 ⟺ 文件存在` 的删除方向:记录没了文件即失效,客户端不再提示、下载 404);删历史版本不动文件。执行顺序「先删记录、再删文件」——记录删除是核心操作,文件删除失败仅记日志不阻断(残留旧文件无害,客户端不会误提示更新)。不存在删除/修改接口(下发后版本号不可变),删除按 `id` 定位
- **写入串行**:新增/删除记录均走 `common.Serial()` 单写者(与授权链路一致,SQLite 无 WAL);列表/最新查询读走普通读,表极小不设查询缓存
- **写入串行**:新增记录走 `common.Serial()` 单写者(与授权链路一致,SQLite 无 WAL);列表/最新查询读走普通读,表极小不设查询缓存
+8 -8
View File
@@ -13,7 +13,7 @@ const page = ref(1)
const size = ref(20)
const addVisible = ref(false)
const addForm = reactive({ version: '', notes: '', file: null })
const addForm = reactive({ notes: '', file: null })
const adding = ref(false)
function load() {
@@ -30,7 +30,6 @@ function load() {
}
function openAdd() {
addForm.version = ''
addForm.notes = ''
addForm.file = null
fileList.value = []
@@ -48,13 +47,12 @@ function submitAdd() {
}
adding.value = true
const fd = new FormData()
fd.append('version', addForm.version.trim())
fd.append('notes', addForm.notes.trim())
fd.append('file', addForm.file)
request
.post('/app-versions', fd, { timeout: 300000 })
.then(() => {
ElMessage.success(`已下发版本 ${addForm.version},覆盖服务器最新 APK`)
ElMessage.success('已下发,覆盖服务器最新 APK')
addVisible.value = false
page.value = 1
load()
@@ -90,7 +88,7 @@ onMounted(load)
<template>
<el-card shadow="never">
<div class="toolbar">
<span class="tip"> Android检测到服务器版本高于手机已装版本即强制更新不可跳过上传 APK 覆盖保存为固定文件服务器永远只保留最新一个下载地址 {{ downloadUrl }}</span>
<span class="tip"> Android检测到服务器版本高于手机已装版本即强制更新不可跳过APK 文件名须为 observer-x.y.z.apk observer-1.0.1.apk版本号从文件名识别上传覆盖保存为固定文件服务器永远只保留最新一个下载地址 {{ downloadUrl }}</span>
<el-button type="primary" @click="openAdd">下发新版本</el-button>
</div>
@@ -121,9 +119,6 @@ onMounted(load)
<el-dialog v-model="addVisible" title="下发新版本(Android" width="480px">
<el-form label-width="90px">
<el-form-item label="版本号">
<el-input v-model="addForm.version" placeholder="如 1.1.0x.y.z,语义化比较,不可重复)" />
</el-form-item>
<el-form-item label="更新说明">
<el-input
v-model="addForm.notes"
@@ -148,6 +143,7 @@ onMounted(load)
<div class="upload-hint">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div>拖拽或点击选择 .apk 文件</div>
<div class="upload-sub">文件名须为 observer-x.y.z.apk(如 observer-1.0.1.apk),版本号从文件名识别</div>
</div>
</el-upload>
</el-form-item>
@@ -184,4 +180,8 @@ onMounted(load)
color: #c0c4cc;
margin-bottom: 6px;
}
.upload-sub {
font-size: 12px;
margin-top: 6px;
}
</style>