feat: add safe PDD rule template editor (#24)

This commit is contained in:
QiuSW
2026-08-15 15:36:58 +08:00
parent 99ca400a74
commit affa6b3f8c
14 changed files with 321 additions and 45 deletions
+1 -1
View File
@@ -32,4 +32,4 @@
## 当前阶段
当前 MVP 的 T01~T07、T09~T20 均已完成实现、验证并由用户验收。T08(只读实时屏幕)已延期且未实施,不属于当前 MVP。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。T21 正在实现 Android v2 商品详情采集器,随后由 T22~T23 完成管理端规则模板和真机验收。当前实施范围仍是采集闭环;Agent 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。
当前 MVP 的 T01~T07、T09~T21 均已完成实现、验证并由用户验收。T08(只读实时屏幕)已延期且未实施,不属于当前 MVP。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。T22 正在提供管理端内置 v2 规则模板和安全编辑表单,随后由 T23 完成一加真机验收。当前实施范围仍是采集闭环;Agent 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。
+3
View File
@@ -27,10 +27,13 @@ POST /api/admin/v1/collection-rules
PATCH /api/admin/v1/collection-rules/{ruleId}
DELETE /api/admin/v1/collection-rules/{ruleId}
GET /api/admin/v1/collection-rules
GET /api/admin/v1/collection-rules/templates/pdd-product-detail-v1
```
规则只有 `name` 和 `content` 等当前值,创建成功后立即可用。删除为软删除;已删除规则不能创建新任务,已有任务继续使用任务内快照。
模板接口返回服务端内置的完整 v2 `content`,以及浏览器包名、别名数量/长度、超时、遍历上限和规格面板滑动动作的可编辑范围。管理端以安全表单修改这些参数,并显示只读 JSON 预览;模板中的采集器 ID、价格解析器、页面根证据、动作类型和语义目标不可在界面中改写。最终创建或更新请求仍由服务端按完整 v2 契约重新校验,不能依赖前端校验。
`content` 支持原有 `schemaVersion: 1` 线性步骤和 `schemaVersion: 2` 类型化采集器契约。v1 契约如下:
```json
@@ -118,4 +118,4 @@ Agent 不限制为“只能采集”,而是注册带版本的类型化能力
## 当前状态
T19 已验收;T20 已实现并验收 v2 规则契约、阶段钩子和设备能力协商。T21 已接入纯 Kotlin 内存节点模型、页面证据、规格入口、颜色归边与蛇形遍历、点击后刷新、选中确认、稳定价格、只读尺码、第三维部分结果和 SKU 上限,当前等待用户验收。管理端内置模板和真机验证分别属于 T22、T23;在两项完成前,规则文件仍标记为 proposed。
T19~T21 已验收:v2 契约、能力协商和 Android 商品详情采集器已经接入。T22 已提供服务端内置模板、参数约束接口、管理端安全编辑表单、只读 JSON 预览,并在任务详情展示颜色价格和规则快照,当前等待用户验收。T23 负责一加真机验证;在 T23 完成前,文档中的规则文件仍标记为 proposed。
+13
View File
@@ -14,6 +14,19 @@ import (
type Handler struct{ DB *gorm.DB }
func (h Handler) Template(c *gin.Context) {
s, ok := h.service(c)
if !ok {
return
}
result, e := s.Template(c.Param("templateId"))
if e != nil {
writeError(c, e)
return
}
c.JSON(200, gin.H{"code": 200, "data": result})
}
func (h Handler) List(c *gin.Context) {
page, e := positive(c.Query("page"), 1)
if e != nil {
+1
View File
@@ -9,6 +9,7 @@ import (
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
handler := Handler{}
admin := engine.Group("/api/admin/v1/collection-rules").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
admin.GET("/templates/:templateId", handler.Template)
admin.GET("", handler.List)
admin.POST("", handler.Create)
admin.PATCH("/:ruleId", handler.Update)
+47
View File
@@ -58,10 +58,57 @@ type ListResponse struct {
Total int64 `json:"total"`
Page, PageSize int
}
type IntRange struct {
Min int `json:"min"`
Max int `json:"max"`
}
type TemplateConstraints struct {
BrowserPackages []string `json:"browserPackages"`
HookDirections []string `json:"hookDirections"`
AliasCount IntRange `json:"aliasCount"`
AliasLength IntRange `json:"aliasLength"`
TimeoutsMS map[string]IntRange `json:"timeoutsMs"`
Limits map[string]IntRange `json:"limits"`
Swipe map[string]IntRange `json:"swipe"`
}
type TemplateResponse struct {
TemplateID string `json:"templateId"`
Name string `json:"name"`
Content json.RawMessage `json:"content"`
Constraints TemplateConstraints `json:"constraints"`
}
type Service struct{ DB *gorm.DB }
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
func (service *Service) Template(templateID string) (TemplateResponse, error) {
if templateID != rulecontract.PDDProductDetailTemplateID {
return TemplateResponse{}, notFound()
}
return TemplateResponse{
TemplateID: templateID,
Name: "PDD 商品详情采集 v2",
Content: rulecontract.PDDProductDetailTemplate(),
Constraints: TemplateConstraints{
BrowserPackages: []string{"com.heytap.browser", "com.android.chrome", "com.android.browser"},
HookDirections: []string{"up", "down", "left", "right"},
AliasCount: IntRange{Min: 1, Max: 20},
AliasLength: IntRange{Min: 1, Max: 30},
TimeoutsMS: map[string]IntRange{
"page": {Min: 100, Max: 60000}, "specPanel": {Min: 100, Max: 30000},
"selection": {Min: 100, Max: 10000}, "price": {Min: 100, Max: 10000},
"overall": {Min: 1000, Max: 600000},
},
Limits: map[string]IntRange{
"goodsPageVerticalSwipes": {Min: 0, Max: 10}, "specHorizontalSwipes": {Min: 0, Max: 30},
"specVerticalSwipes": {Min: 0, Max: 30}, "stableEdgeReads": {Min: 1, Max: 5},
"stablePriceReads": {Min: 2, Max: 5}, "maxSkuCount": {Min: 1, Max: 2000},
},
Swipe: map[string]IntRange{"count": {Min: 1, Max: 5}, "settleMs": {Min: 0, Max: 2000}},
},
}, nil
}
func (service *Service) Create(ctx context.Context, request SaveRequest) (SaveResponse, error) {
name, content, err := validateSave(request)
if err != nil {
+34
View File
@@ -49,6 +49,40 @@ func TestRuleRequiresVersionedJSONObject(t *testing.T) {
}
}
func TestPDDProductDetailTemplateCanBeCreated(t *testing.T) {
service := NewService(openRuleDatabase(t))
template, err := service.Template("pdd-product-detail-v1")
if err != nil {
t.Fatal(err)
}
if template.TemplateID != "pdd-product-detail-v1" || len(template.Content) == 0 {
t.Fatalf("bad template: %+v", template)
}
if _, err = service.Create(context.Background(), SaveRequest{RequestID: uuid.NewString(), Name: template.Name, Content: template.Content}); err != nil {
t.Fatalf("template could not create a rule: %v", err)
}
if _, err = service.Template("unknown"); ruleErrorCode(t, err) != CodeRuleNotFound {
t.Fatalf("unknown template error: %v", err)
}
}
func TestPDDTemplateRejectsUnknownCapabilityAndDangerousAction(t *testing.T) {
service := NewService(openRuleDatabase(t))
template, err := service.Template("pdd-product-detail-v1")
if err != nil {
t.Fatal(err)
}
invalid := []string{
strings.Replace(string(template.Content), "pddProductDetailV1", "pddCreateOrderV1", 1),
strings.Replace(string(template.Content), "打开拼多多APP", "提交订单", 1),
}
for _, content := range invalid {
if _, err = service.Create(context.Background(), saveRequest("invalid v2", content)); ruleErrorCode(t, err) != CodeRuleInvalid {
t.Fatalf("unsafe template mutation accepted: %v", err)
}
}
}
func TestRuleLifecycleIsIdempotentAndSoftDeletes(t *testing.T) {
db := openRuleDatabase(t)
service := NewService(db)
@@ -55,3 +55,17 @@ func TestLegacyV1RequiresNoReportedCapabilities(t *testing.T) {
t.Fatalf("legacy capability behavior changed: %v %v", required, err)
}
}
func TestBuiltInPDDProductDetailTemplateIsValid(t *testing.T) {
raw := PDDProductDetailTemplate()
if err := Validate(raw); err != nil {
t.Fatalf("built-in template invalid: %v", err)
}
required, err := RequiredCapabilities(string(raw))
if err != nil {
t.Fatal(err)
}
if strings.Join(required, ",") != "collector.pdd.product-detail.v1,rule.schema.v2" {
t.Fatalf("unexpected template capabilities: %v", required)
}
}
@@ -0,0 +1,25 @@
package rulecontract
import "encoding/json"
const PDDProductDetailTemplateID = "pdd-product-detail-v1"
var pddProductDetailTemplate = json.RawMessage(`{
"schemaVersion":2,"ruleType":"pddProductDetail",
"navigation":{"steps":[
{"id":"browser-open-pdd","page":"browser","packageName":"com.heytap.browser","action":"click","selector":{"text":"打开拼多多APP"},"timeoutMs":15000,"optional":true},
{"id":"system-open","page":"system-confirm","packageName":"android","action":"click","selector":{"text":"打开"},"timeoutMs":8000,"optional":true}
]},
"pageEvidence":{"packageName":"com.xunmeng.pinduoduo","activityName":"com.xunmeng.pinduoduo.activity.NewPageActivity","selector":{"resourceId":"android:id/content","className":"android.widget.FrameLayout"}},
"hooks":{"afterSpecPanelOpen":[]},
"collector":{
"collectorId":"pddProductDetailV1","specEntryStrategy":"safeBottomSpecEntryV1","priceParser":"pddRmbPriceV1","priceGranularity":"color",
"dimensionAliases":{"color":["颜色分类","颜色","款式","颜色款式","花色"],"size":["尺码","尺寸","套餐"]},
"timeoutsMs":{"page":30000,"specPanel":10000,"selection":2000,"price":2000,"overall":180000},
"limits":{"goodsPageVerticalSwipes":3,"specHorizontalSwipes":12,"specVerticalSwipes":12,"stableEdgeReads":2,"stablePriceReads":2,"maxSkuCount":500}
}
}`)
func PDDProductDetailTemplate() json.RawMessage {
return append(json.RawMessage(nil), pddProductDetailTemplate...)
}
+4 -1
View File
@@ -8,8 +8,11 @@ export default defineConfig({
use: {
baseURL: 'http://localhost:9527',
headless: true,
launchOptions: {
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined,
},
screenshot: 'on',
video: 'retain-on-failure',
video: process.env.PLAYWRIGHT_DISABLE_VIDEO === '1' ? 'off' : 'retain-on-failure',
trace: 'retain-on-failure',
},
});
+1
View File
@@ -1,6 +1,7 @@
import request from '@/utils/request'
export function listCollectionRules(params) { return request({ url: '/api/admin/v1/collection-rules', method: 'get', params }) }
export function getCollectionRuleTemplate(templateId = 'pdd-product-detail-v1') { return request({ url: `/api/admin/v1/collection-rules/templates/${templateId}`, method: 'get' }) }
export function createCollectionRule(data) { return request({ url: '/api/admin/v1/collection-rules', method: 'post', data }) }
export function updateCollectionRule(ruleId, data) { return request({ url: `/api/admin/v1/collection-rules/${ruleId}`, method: 'patch', data }) }
export function deleteCollectionRule(ruleId, data) { return request({ url: `/api/admin/v1/collection-rules/${ruleId}`, method: 'delete', data }) }
+115 -41
View File
@@ -2,8 +2,8 @@
<BasicLayout><template #wrapper>
<el-card class="rule-card" shadow="never">
<div class="page-heading">
<div><h1>采集规则</h1><p>规则创建后立即可用;编辑或删除不会改变已有任务的规则快照。</p></div>
<el-button type="primary" :icon="Plus" @click="openCreate">创建规则</el-button>
<div><h1>采集规则</h1><p>通过安全表单维护 PDD 页面适配参数;规则保存后立即用于新任务。</p></div>
<el-button type="primary" :icon="Plus" @click="openCreate">创建 PDD 采集规则</el-button>
</div>
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="handleQuery">
<el-form-item label="规则名称"><el-input v-model="query.name" placeholder="输入规则名称" clearable @keyup.enter="handleQuery" /></el-form-item>
@@ -12,20 +12,68 @@
<el-table v-loading="loading" :data="rulesList" border stripe empty-text="暂无采集规则">
<el-table-column label="ID" prop="id" width="90" />
<el-table-column label="规则名称" prop="name" min-width="180" />
<el-table-column label="Schema" width="110" align="center"><template #default="{ row }"><el-tag>v{{ row.content.schemaVersion }}</el-tag></template></el-table-column>
<el-table-column label="规则内容" min-width="380"><template #default="{ row }"><code class="json-preview">{{ compact(row.content) }}</code></template></el-table-column>
<el-table-column label="类型" width="180"><template #default="{ row }"><el-tag :type="row.content.schemaVersion === 2 ? 'success' : 'info'">{{ ruleType(row.content) }}</el-tag></template></el-table-column>
<el-table-column label="规则摘要" min-width="360"><template #default="{ row }"><span class="rule-summary">{{ summary(row.content) }}</span></template></el-table-column>
<el-table-column label="更新时间" width="180"><template #default="{ row }">{{ parseTime(row.updatedAt) }}</template></el-table-column>
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openEdit(row)">编辑</el-button><el-button type="danger" link @click="remove(row)">删除</el-button></template></el-table-column>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="getList" />
</el-card>
<el-dialog v-model="dialog.open" :title="dialog.ruleId ? '编辑采集规则' : '创建采集规则'" width="760px" :close-on-click-modal="false">
<el-alert title="当前规则格式要求 JSON 对象包含 schemaVersion: 1。创建成功后立即可用于新任务。" type="info" :closable="false" show-icon class="dialog-alert" />
<el-form ref="ruleForm" :model="form" :rules="formRules" label-position="top">
<el-form-item label="规则名称" prop="name"><el-input v-model="form.name" maxlength="120" show-word-limit /></el-form-item>
<el-form-item label="规则 JSON" prop="contentText"><el-input v-model="form.contentText" type="textarea" :rows="16" class="json-editor" spellcheck="false" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialog.open = false">取消</el-button><el-button type="primary" :loading="dialog.saving" @click="save">保存</el-button></template>
<el-dialog v-model="dialog.open" :title="dialog.ruleId ? '编辑采集规则' : '创建 PDD 商品详情规则'" width="min(980px, 94vw)" :close-on-click-modal="false" :close-on-press-escape="!dialog.saving">
<div v-loading="dialog.loading">
<el-alert v-if="form.mode === 'v2'" title="仅编辑 Agent 已支持的参数;采集器、价格解析器和页面根证据由模板固定。保存后立即生效,但不会改变已有任务快照。" type="info" :closable="false" show-icon class="dialog-alert" />
<el-alert v-else title="这是旧版 v1 规则。可继续维护,但新建规则统一使用 PDD 商品详情 v2 模板。" type="warning" :closable="false" show-icon class="dialog-alert" />
<el-form ref="ruleForm" :model="form" :rules="formRules" label-position="top" @submit.prevent>
<el-form-item label="规则名称" prop="name"><el-input v-model="form.name" maxlength="120" show-word-limit /></el-form-item>
<template v-if="form.mode === 'v2'">
<section class="form-section" aria-labelledby="page-settings-title">
<div class="section-heading"><div><h3 id="page-settings-title">页面识别</h3><p>用于浏览器跳转和 PDD 商品详情页精确校验。</p></div><el-tag type="success">固定能力 v1</el-tag></div>
<div class="form-grid two-columns">
<el-form-item label="浏览器" prop="browserPackage"><el-select v-model="form.browserPackage" style="width:100%"><el-option v-for="item in browserOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="PDD 商品页 Activity" prop="activityName"><el-input v-model="form.activityName" spellcheck="false" /><div class="field-help">必须与真机商品详情页 Activity 完全一致。</div></el-form-item>
</div>
</section>
<section class="form-section" aria-labelledby="dimension-settings-title">
<div class="section-heading"><div><h3 id="dimension-settings-title">规格维度别名</h3><p>回车添加别名;每组 1~20 项,每项不超过 30 个字符。</p></div></div>
<div class="form-grid two-columns">
<el-form-item label="颜色分类别名" prop="colorAliases"><el-select v-model="form.colorAliases" multiple filterable allow-create default-first-option style="width:100%" placeholder="输入后按回车" /></el-form-item>
<el-form-item label="尺码分类别名" prop="sizeAliases"><el-select v-model="form.sizeAliases" multiple filterable allow-create default-first-option style="width:100%" placeholder="输入后按回车" /></el-form-item>
</div>
</section>
<section class="form-section" aria-labelledby="timeout-settings-title">
<div class="section-heading"><div><h3 id="timeout-settings-title">超时设置</h3><p>单位为毫秒;界面和服务端会同时校验范围。</p></div></div>
<div class="form-grid numeric-grid">
<el-form-item v-for="item in timeoutFields" :key="item.key" :label="item.label" :prop="`timeouts.${item.key}`"><el-input-number v-model="form.timeouts[item.key]" :min="range('timeoutsMs', item.key).min" :max="range('timeoutsMs', item.key).max" :step="item.step" controls-position="right" /></el-form-item>
</div>
</section>
<section class="form-section" aria-labelledby="limit-settings-title">
<div class="section-heading"><div><h3 id="limit-settings-title">遍历上限</h3><p>所有循环都有硬上限,超出时提交已有数据并标记部分完成。</p></div></div>
<div class="form-grid numeric-grid">
<el-form-item v-for="item in limitFields" :key="item.key" :label="item.label" :prop="`limits.${item.key}`"><el-input-number v-model="form.limits[item.key]" :min="range('limits', item.key).min" :max="range('limits', item.key).max" controls-position="right" /></el-form-item>
</div>
</section>
<section class="form-section" aria-labelledby="hook-settings-title">
<div class="section-heading"><div><h3 id="hook-settings-title">规格面板打开后动作</h3><p>例如页面变化后需要先向上滑动两次。关闭时不执行附加动作。</p></div><el-switch v-model="form.hook.enabled" active-text="启用" inactive-text="关闭" /></div>
<div v-if="form.hook.enabled" class="form-grid hook-grid">
<el-form-item label="方向"><el-select v-model="form.hook.direction" style="width:100%"><el-option v-for="item in directionOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="次数"><el-input-number v-model="form.hook.count" :min="range('swipe', 'count').min" :max="range('swipe', 'count').max" controls-position="right" /></el-form-item>
<el-form-item label="动作后等待(毫秒)"><el-input-number v-model="form.hook.settleMs" :min="range('swipe', 'settleMs').min" :max="range('swipe', 'settleMs').max" :step="50" controls-position="right" /></el-form-item>
</div>
</section>
<el-collapse class="json-collapse"><el-collapse-item title="查看只读 JSON 预览" name="preview"><pre class="json-preview-block" tabindex="0">{{ previewText }}</pre></el-collapse-item></el-collapse>
</template>
<el-form-item v-else label="旧版规则 JSON" prop="contentText"><el-input v-model="form.contentText" type="textarea" :rows="16" class="json-editor" spellcheck="false" /></el-form-item>
</el-form>
</div>
<template #footer><el-button :disabled="dialog.saving" @click="dialog.open = false">取消</el-button><el-button type="primary" :loading="dialog.saving" :disabled="dialog.loading" @click="save">保存并立即生效</el-button></template>
</el-dialog>
</template></BasicLayout>
</template>
@@ -33,51 +81,77 @@
<script>
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
import { createCollectionRule, deleteCollectionRule, listCollectionRules, updateCollectionRule } from '@/api/goauto/collection-rules'
import { createCollectionRule, deleteCollectionRule, getCollectionRuleTemplate, listCollectionRules, updateCollectionRule } from '@/api/goauto/collection-rules'
const defaultConstraints = {
browserPackages: ['com.heytap.browser', 'com.android.chrome', 'com.android.browser'], hookDirections: ['up', 'down', 'left', 'right'],
aliasCount: { min: 1, max: 20 }, aliasLength: { min: 1, max: 30 },
timeoutsMs: { page: { min: 100, max: 60000 }, specPanel: { min: 100, max: 30000 }, selection: { min: 100, max: 10000 }, price: { min: 100, max: 10000 }, overall: { min: 1000, max: 600000 }},
limits: { goodsPageVerticalSwipes: { min: 0, max: 10 }, specHorizontalSwipes: { min: 0, max: 30 }, specVerticalSwipes: { min: 0, max: 30 }, stableEdgeReads: { min: 1, max: 5 }, stablePriceReads: { min: 2, max: 5 }, maxSkuCount: { min: 1, max: 2000 }},
swipe: { count: { min: 1, max: 5 }, settleMs: { min: 0, max: 2000 }}
}
const timeoutFields = [{ key: 'page', label: '商品页', step: 1000 }, { key: 'specPanel', label: '规格面板', step: 500 }, { key: 'selection', label: '选中确认', step: 100 }, { key: 'price', label: '价格稳定', step: 100 }, { key: 'overall', label: '任务总时长', step: 5000 }]
const limitFields = [{ key: 'goodsPageVerticalSwipes', label: '商品页纵向滑动' }, { key: 'specHorizontalSwipes', label: '规格横向滑动' }, { key: 'specVerticalSwipes', label: '规格纵向滑动' }, { key: 'stableEdgeReads', label: '边界稳定读取' }, { key: 'stablePriceReads', label: '价格稳定读取' }, { key: 'maxSkuCount', label: '最大 SKU 数量' }]
const initialContent = JSON.stringify({ schemaVersion: 1, packageName: 'com.xunmeng.pinduoduo', steps: [] }, null, 2)
export default {
name: 'GoAutoCollectionRules',
setup() { return { Plus, RefreshLeft, Search } },
data() {
const validJson = (_rule, value, callback) => {
try {
const parsed = JSON.parse(value)
if (!parsed || Array.isArray(parsed) || parsed.schemaVersion !== 1) return callback(new Error('必须是包含 schemaVersion: 1 的 JSON 对象'))
callback()
} catch (_) { callback(new Error('请输入有效 JSON')) }
const aliases = (_rule, value, callback) => {
const count = this.constraints.aliasCount
const length = this.constraints.aliasLength
const normalized = (value || []).map(item => String(item).trim()).filter(Boolean)
if (normalized.length < count.min || normalized.length > count.max) return callback(new Error(`需要 ${count.min}~${count.max} 个别名`))
if (new Set(normalized).size !== normalized.length) return callback(new Error('别名不能重复'))
if (normalized.some(item => item.length < length.min || item.length > length.max)) return callback(new Error(`每个别名长度必须为 ${length.min}~${length.max}`))
callback()
}
const legacyJson = (_rule, value, callback) => {
try { const parsed = JSON.parse(value); if (!parsed || Array.isArray(parsed) || parsed.schemaVersion !== 1) return callback(new Error('旧版规则必须是 schemaVersion: 1 的 JSON 对象')); callback() } catch (_) { callback(new Error('请输入有效 JSON')) }
}
return {
loading: false, rulesList: [], total: 0, query: { page: 1, pageSize: 20, name: '' },
dialog: { open: false, saving: false, ruleId: null }, form: { name: '', contentText: initialContent },
formRules: { name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }], contentText: [{ required: true, validator: validJson, trigger: 'blur' }] }
loading: false, rulesList: [], total: 0, query: { page: 1, pageSize: 20, name: '' }, serverTemplate: null, contentBase: null, constraints: defaultConstraints,
dialog: { open: false, loading: false, saving: false, ruleId: null },
form: {},
timeoutFields, limitFields,
formRules: {
name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
browserPackage: [{ required: true, message: '请选择浏览器', trigger: 'change' }],
activityName: [{ required: true, pattern: /^[A-Za-z0-9_.$]+$/, message: '请输入完整、精确的 Activity 类名', trigger: 'blur' }],
colorAliases: [{ validator: aliases, trigger: 'change' }], sizeAliases: [{ validator: aliases, trigger: 'change' }],
contentText: [{ required: true, validator: legacyJson, trigger: 'blur' }]
}
}
},
created() { this.getList() },
computed: {
browserOptions() { const labels = { 'com.heytap.browser': 'OPPO / 一加浏览器', 'com.android.chrome': 'Google Chrome', 'com.android.browser': 'Android 系统浏览器' }; return this.constraints.browserPackages.map(value => ({ value, label: labels[value] || value })) },
directionOptions() { const labels = { up: '向上', down: '向下', left: '向左', right: '向右' }; return this.constraints.hookDirections.map(value => ({ value, label: labels[value] || value })) },
previewText() { return this.form.mode === 'v2' ? JSON.stringify(this.buildV2Content(), null, 2) : this.form.contentText }
},
created() { this.form = this.emptyForm(); this.getList() },
methods: {
emptyForm() { return { mode: 'v2', name: '', contentText: '', browserPackage: '', activityName: '', colorAliases: [], sizeAliases: [], timeouts: {}, limits: {}, hook: { enabled: false, direction: 'up', count: 1, settleMs: 350 }} },
clone(value) { return JSON.parse(JSON.stringify(value)) },
range(group, key) { return this.constraints[group]?.[key] || { min: 0, max: 999999 } },
async ensureTemplate() { if (this.serverTemplate) return; const response = await getCollectionRuleTemplate(); this.serverTemplate = this.clone(response.data.content); this.constraints = response.data.constraints || defaultConstraints },
fillV2(content, name = '') { this.contentBase = this.clone(content); const hook = content.hooks?.afterSpecPanelOpen?.[0]; this.form = { mode: 'v2', name, contentText: '', browserPackage: content.navigation.steps[0].packageName, activityName: content.pageEvidence.activityName, colorAliases: [...content.collector.dimensionAliases.color], sizeAliases: [...content.collector.dimensionAliases.size], timeouts: { ...content.collector.timeoutsMs }, limits: { ...content.collector.limits }, hook: { enabled: Boolean(hook), direction: hook?.direction || 'up', count: hook?.count || 1, settleMs: hook?.settleMs ?? 350 }} },
buildV2Content() { if (!this.contentBase) return {}; const content = this.clone(this.contentBase); content.navigation.steps[0].packageName = this.form.browserPackage; content.pageEvidence.activityName = this.form.activityName.trim(); content.collector.dimensionAliases.color = this.form.colorAliases.map(item => item.trim()).filter(Boolean); content.collector.dimensionAliases.size = this.form.sizeAliases.map(item => item.trim()).filter(Boolean); content.collector.timeoutsMs = { ...this.form.timeouts }; content.collector.limits = { ...this.form.limits }; content.hooks.afterSpecPanelOpen = this.form.hook.enabled ? [{ action: 'swipe', target: 'specPanel', direction: this.form.hook.direction, count: this.form.hook.count, settleMs: this.form.hook.settleMs }] : []; return content },
async getList() { this.loading = true; try { const response = await listCollectionRules(this.query); this.rulesList = response.data.items; this.total = response.data.total } finally { this.loading = false } },
handleQuery() { this.query.page = 1; this.getList() },
resetQuery() { this.query = { page: 1, pageSize: 20, name: '' }; this.getList() },
compact(content) { const text = JSON.stringify(content); return text.length > 120 ? `${text.slice(0, 120)}…` : text },
openCreate() { this.dialog = { open: true, saving: false, ruleId: null }; this.form = { name: '', contentText: initialContent }; this.$nextTick(() => this.$refs.ruleForm?.clearValidate()) },
openEdit(row) { this.dialog = { open: true, saving: false, ruleId: row.id }; this.form = { name: row.name, contentText: JSON.stringify(row.content, null, 2) }; this.$nextTick(() => this.$refs.ruleForm?.clearValidate()) },
async save() {
const valid = await this.$refs.ruleForm.validate().catch(() => false); if (!valid) return
this.dialog.saving = true
const payload = { requestId: window.crypto.randomUUID(), name: this.form.name.trim(), content: JSON.parse(this.form.contentText) }
try { if (this.dialog.ruleId) await updateCollectionRule(this.dialog.ruleId, payload); else await createCollectionRule(payload); ElMessage.success(this.dialog.ruleId ? '规则已更新,新任务将使用新内容' : '规则已创建并立即生效'); this.dialog.open = false; await this.getList() } finally { this.dialog.saving = false }
},
handleQuery() { this.query.page = 1; this.getList() }, resetQuery() { this.query = { page: 1, pageSize: 20, name: '' }; this.getList() },
ruleType(content) { return content.schemaVersion === 2 ? 'PDD 商品详情 v2' : `旧版 v${content.schemaVersion || '?'}` },
summary(content) { if (content.schemaVersion !== 2) { const text = JSON.stringify(content); return text.length > 100 ? `${text.slice(0, 100)}…` : text } const aliases = content.collector?.dimensionAliases; const hookCount = content.hooks?.afterSpecPanelOpen?.[0]?.count || 0; return `颜色别名 ${aliases?.color?.length || 0} · 尺码别名 ${aliases?.size?.length || 0} · 附加滑动 ${hookCount} 次` },
async openCreate() { this.dialog = { open: true, loading: true, saving: false, ruleId: null }; try { await this.ensureTemplate(); this.fillV2(this.serverTemplate, 'PDD 商品详情采集') } finally { this.dialog.loading = false; this.$nextTick(() => this.$refs.ruleForm?.clearValidate()) } },
async openEdit(row) { this.dialog = { open: true, loading: true, saving: false, ruleId: row.id }; try { if (row.content.schemaVersion === 2) { await this.ensureTemplate(); this.fillV2(row.content, row.name) } else { this.contentBase = null; this.form = { ...this.emptyForm(), mode: 'v1', name: row.name, contentText: JSON.stringify(row.content, null, 2) } } } finally { this.dialog.loading = false; this.$nextTick(() => this.$refs.ruleForm?.clearValidate()) } },
async save() { const valid = await this.$refs.ruleForm.validate().catch(() => false); if (!valid) return; this.dialog.saving = true; const content = this.form.mode === 'v2' ? this.buildV2Content() : JSON.parse(this.form.contentText); const payload = { requestId: window.crypto.randomUUID(), name: this.form.name.trim(), content }; try { if (this.dialog.ruleId) await updateCollectionRule(this.dialog.ruleId, payload); else await createCollectionRule(payload); ElMessage.success(this.dialog.ruleId ? '规则已更新,新任务将使用新内容' : '规则已创建并立即生效'); this.dialog.open = false; await this.getList() } finally { this.dialog.saving = false } },
async remove(row) { await ElMessageBox.confirm(`删除“${row.name}”后不能再用于创建新任务,已有任务不受影响。`, '确认删除规则', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }); await deleteCollectionRule(row.id, { requestId: window.crypto.randomUUID() }); ElMessage.success('规则已删除'); await this.getList() }
}
}
</script>
<style lang="scss" scoped>
.rule-card { min-height: calc(100vh - 124px); }
.page-heading { display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:24px; h1{margin:0 0 6px;color:#1f2937;font-size:24px;line-height:1.35} p{margin:0;color:#606266;line-height:1.5} }
.search-form { padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc; }
.json-preview { color:#334155;font-family:"Cascadia Code",Consolas,monospace;font-size:12px;overflow-wrap:anywhere; }
.dialog-alert { margin-bottom:20px; }
:deep(.json-editor textarea) { font-family:"Cascadia Code",Consolas,monospace;line-height:1.6; }
@media(max-width:768px){.page-heading{align-items:stretch;flex-direction:column}}
.rule-card{min-height:calc(100vh - 124px)}
.page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:24px;h1{margin:0 0 6px;color:#1f2937;font-size:24px;line-height:1.35}p{margin:0;color:#606266;line-height:1.5}}
.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.rule-summary{color:#475569}.dialog-alert{margin-bottom:20px}
.form-section{margin-bottom:18px;padding:18px;border:1px solid #e5e7eb;border-radius:8px;background:#fff}.section-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:16px}.section-heading h3{margin:0 0 4px;color:#1f2937;font-size:16px}.section-heading p,.field-help{margin:0;color:#64748b;font-size:13px;line-height:1.5}.field-help{margin-top:6px}.form-grid{display:grid;gap:0 16px}.two-columns{grid-template-columns:repeat(2,minmax(0,1fr))}.numeric-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.hook-grid{grid-template-columns:1.2fr 1fr 1.4fr}.form-grid :deep(.el-input-number){width:100%}.json-collapse{margin-top:8px}.json-preview-block{max-height:360px;margin:0;padding:16px;overflow:auto;border-radius:6px;background:#0f172a;color:#e2e8f0;font-family:"Cascadia Code",Consolas,monospace;font-size:12px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere}:deep(.json-editor textarea){font-family:"Cascadia Code",Consolas,monospace;line-height:1.6}
@media(max-width:768px){.page-heading{align-items:stretch;flex-direction:column}.two-columns,.numeric-grid,.hook-grid{grid-template-columns:1fr}.form-section{padding:14px}.section-heading{flex-direction:column}}
</style>
@@ -46,8 +46,10 @@
<el-descriptions-item label="评价数">{{ detail.data.task.reviewCount ?? '未采集' }}</el-descriptions-item><el-descriptions-item label="错误">{{ detail.data.task.errorCode ? `${detail.data.task.errorCode}:${detail.data.task.errorMessage}` : '无' }}</el-descriptions-item>
</el-descriptions>
<h3>规格维度</h3><el-table :data="detail.data.dimensions" border empty-text="暂无规格"><el-table-column prop="name" label="维度" width="120" /><el-table-column label="规格值"><template #default="{ row }">{{ row.values.join('、') }}</template></el-table-column></el-table>
<h3>颜色价格</h3><el-table :data="detail.data.colorPrices" border empty-text="暂无颜色价格"><el-table-column prop="color" label="颜色" /><el-table-column label="价格" width="140"><template #default="{ row }">¥{{ (row.priceCent / 100).toFixed(2) }}</template></el-table-column></el-table>
<h3>SKU</h3><el-table :data="detail.data.skus" border empty-text="暂无 SKU"><el-table-column label="规格"><template #default="{ row }">{{ specsText(row.specs) }}</template></el-table-column><el-table-column label="价格" width="120"><template #default="{ row }">¥{{ (row.priceCent / 100).toFixed(2) }}</template></el-table-column><el-table-column label="完整" width="90"><template #default="{ row }">{{ row.complete ? '是' : '否' }}</template></el-table-column></el-table>
<el-alert v-if="detail.data.missing?.length" :title="`缺失项:${detail.data.missing.join('、')}`" type="warning" :closable="false" show-icon class="missing-alert" />
<el-collapse class="snapshot-collapse"><el-collapse-item title="查看本任务规则快照" name="rule-snapshot"><pre class="snapshot-json" tabindex="0">{{ formatRuleSnapshot(detail.data.task.ruleSnapshot) }}</pre></el-collapse-item></el-collapse>
</div>
</el-drawer>
</template>
@@ -71,6 +73,7 @@ export default {
handleQuery() { this.query.page = 1; this.getList() }, resetQuery() { this.query = { page: 1, pageSize: 20, goodsId: '', status: '' }; this.getList() },
statusLabel(value) { return this.statuses.find(item => item.value === value)?.label || value }, statusType(value) { return { pending: 'info', running: 'warning', completed: 'success', completed_partial: 'warning', failed: 'danger' }[value] || 'info' },
isTerminal(value) { return ['completed', 'completed_partial', 'failed'].includes(value) }, specsText(specs) { return Object.entries(specs || {}).map(([key, value]) => `${key}: ${value}`).join(' / ') },
formatRuleSnapshot(value) { try { return JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value, null, 2) } catch (_) { return String(value || '') } },
async openCreate() { this.form = { pddProductId: null, ruleId: null, deviceId: null }; this.createDialog.open = true; const [p, r, d] = await Promise.all([listPddProducts({ page: 1, pageSize: 100 }), listCollectionRules({ page: 1, pageSize: 100 }), listDevices({ page: 1, pageSize: 100 })]); this.options = { products: p.data.items, rules: r.data.items, devices: d.data.items }; this.$nextTick(() => this.$refs.taskForm?.clearValidate()) },
async createTask() { if (!await this.$refs.taskForm.validate().catch(() => false)) return; this.createDialog.saving = true; try { await createCollectionTask({ requestId: window.crypto.randomUUID(), pddProductId: this.form.pddProductId, ruleId: this.form.ruleId, deviceId: this.form.deviceId || null }); ElMessage.success('任务已创建'); this.createDialog.open = false; await this.getList() } finally { this.createDialog.saving = false } },
async showDetail(row) { this.detail = { open: true, loading: true, data: null }; try { const r = await getCollectionTask(row.id); this.detail.data = r.data } finally { this.detail.loading = false } },
@@ -81,5 +84,5 @@ export default {
</script>
<style lang="scss" scoped>
.task-card{min-height:calc(100vh - 124px)} .page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:24px}.page-heading h1{margin:0 0 6px;color:#1f2937;font-size:24px}.page-heading p{margin:0;color:#606266}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.dialog-alert{margin-bottom:20px}.detail-body h3{margin:24px 0 12px;color:#303133}.missing-alert{margin-top:20px}@media(max-width:768px){.page-heading{flex-direction:column}.detail-body{min-width:0}}
.task-card{min-height:calc(100vh - 124px)} .page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:24px}.page-heading h1{margin:0 0 6px;color:#1f2937;font-size:24px}.page-heading p{margin:0;color:#606266}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.dialog-alert{margin-bottom:20px}.detail-body h3{margin:24px 0 12px;color:#303133}.missing-alert{margin-top:20px}.snapshot-collapse{margin-top:20px}.snapshot-json{max-height:320px;margin:0;padding:16px;overflow:auto;border-radius:6px;background:#0f172a;color:#e2e8f0;font-family:"Cascadia Code",Consolas,monospace;font-size:12px;line-height:1.6;white-space:pre-wrap;overflow-wrap:anywhere}@media(max-width:768px){.page-heading{flex-direction:column}.detail-body{min-width:0}}
</style>
@@ -0,0 +1,58 @@
import { test, expect } from '@playwright/test'
const template = {
schemaVersion: 2,
ruleType: 'pddProductDetail',
navigation: { steps: [
{ id: 'browser-open-pdd', page: 'browser', packageName: 'com.heytap.browser', action: 'click', selector: { text: '打开拼多多APP' }, timeoutMs: 15000, optional: true },
{ id: 'system-open', page: 'system-confirm', packageName: 'android', action: 'click', selector: { text: '打开' }, timeoutMs: 8000, optional: true },
] },
pageEvidence: { packageName: 'com.xunmeng.pinduoduo', activityName: 'com.xunmeng.pinduoduo.activity.NewPageActivity', selector: { resourceId: 'android:id/content', className: 'android.widget.FrameLayout' } },
hooks: { afterSpecPanelOpen: [] },
collector: {
collectorId: 'pddProductDetailV1', specEntryStrategy: 'safeBottomSpecEntryV1', priceParser: 'pddRmbPriceV1', priceGranularity: 'color',
dimensionAliases: { color: ['颜色分类', '颜色'], size: ['尺码', '尺寸'] },
timeoutsMs: { page: 30000, specPanel: 10000, selection: 2000, price: 2000, overall: 180000 },
limits: { goodsPageVerticalSwipes: 3, specHorizontalSwipes: 12, specVerticalSwipes: 12, stableEdgeReads: 2, stablePriceReads: 2, maxSkuCount: 500 },
},
}
test('v2 rule uses safe form and read-only JSON preview', async ({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'test-token', domain: 'localhost', path: '/' }])
await page.route('**/api/v1/getinfo', route => route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: 'admin', avatar: '', introduction: '', permissions: [] } } }))
await page.route('**/api/v1/menurole', route => route.fulfill({ json: { code: 200, data: [] } }))
await page.route('**/api/admin/v1/collection-rules/templates/pdd-product-detail-v1', route => route.fulfill({ json: { code: 200, data: { templateId: 'pdd-product-detail-v1', name: 'PDD 商品详情采集 v2', content: template } } }))
await page.route(/\/api\/admin\/v1\/collection-rules\?(?!.*templates).*/, route => route.fulfill({ json: { code: 200, data: { items: [], total: 0, page: 1, pageSize: 20 } } }))
await page.goto('/#/collection-rules/index')
await expect(page.getByRole('heading', { name: '采集规则' })).toBeVisible()
await page.getByRole('button', { name: '创建 PDD 采集规则' }).click()
await expect(page.getByRole('heading', { name: '页面识别' })).toBeVisible()
await expect(page.getByRole('heading', { name: '规格维度别名' })).toBeVisible()
await expect(page.getByRole('heading', { name: '超时设置' })).toBeVisible()
await expect(page.getByRole('heading', { name: '遍历上限' })).toBeVisible()
await expect(page.getByText('固定能力 v1')).toBeVisible()
await expect(page.locator('.json-editor')).toHaveCount(0)
await page.getByText('查看只读 JSON 预览').click()
await expect(page.locator('.json-preview-block')).toContainText('pddProductDetailV1')
await expect(page.locator('.json-preview-block')).not.toContainText('提交订单')
await expect(page.getByRole('button', { name: '保存并立即生效' })).toBeEnabled()
})
test('task detail shows structured color prices and v2 snapshot', async ({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'test-token', domain: 'localhost', path: '/' }])
await page.route('**/api/v1/getinfo', route => route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: 'admin', avatar: '', introduction: '', permissions: [] } } }))
await page.route('**/api/v1/menurole', route => route.fulfill({ json: { code: 200, data: [] } }))
const task = { id: 88, goodsIdSnapshot: '719834019024', ruleName: 'PDD 商品详情采集', deviceName: 'OPPO-PKG110', status: 'completed_partial', createdAt: '2026-08-15T00:00:00Z', title: '测试商品', shopName: '测试旗舰店', salesText: '已拼100件', reviewCount: 20, ruleSnapshot: JSON.stringify(template) }
await page.route(/\/api\/admin\/v1\/collection-tasks\?.*/, route => route.fulfill({ json: { code: 200, data: { items: [task], total: 1, page: 1, pageSize: 20 } } }))
await page.route('**/api/admin/v1/collection-tasks/88', route => route.fulfill({ json: { code: 200, data: { task, dimensions: [{ key: 'color', name: '颜色', values: ['红色'] }], colorPrices: [{ color: '红色', priceCent: 1099 }], skus: [{ specs: { color: '红色' }, priceCent: 1099, complete: false }], missing: ['size'] } } }))
await page.goto('/#/collection-tasks/index')
await expect(page.getByText('719834019024')).toBeVisible()
await page.getByRole('button', { name: '详情' }).click()
await expect(page.getByRole('heading', { name: '颜色价格' })).toBeVisible()
await expect(page.getByText('¥10.99').first()).toBeVisible()
await page.getByText('查看本任务规则快照').click()
await expect(page.locator('.snapshot-json')).toContainText('pddProductDetailV1')
})