feat(settings): accept duration units while preserving seconds
douyin-release-gate / verify (push) Failing after 18m35s
douyin-release-gate / verify (push) Failing after 18m35s
This commit is contained in:
@@ -64,6 +64,8 @@
|
||||
|
||||
评论聚合:独立页面通过“我的作品评论”“竞品作品评论”两个 TAB 严格区分来源,各 TAB 保留筛选与页码。列表展示评论发布时间、评论者、内容、所属账号名称及对应作品的小封面;所属账号列与筛选下拉均不展示 UID,缺名仅显示前端“未命名账号”,评论者显示不变;作品使用本地封面,点击在新标签页打开抖音作品页面,缺图明确提示,不展示作品标题、不使用远程图片兜底。支持所属账号筛选,以及最近 1/6/12 小时、1/3/5/7 天筛选,默认最近 1 天。按评论发布时间计算范围,最新评论在前,同时间按评论 ID 倒序;未记录发布时间、未来时间及未采集评论不参与。筛选先作用于全部已采集评论再分页,条件或每页数量变化回到第一页;只读展示,不新增采集机制。
|
||||
|
||||
采集时长设置:新作品/一级评论间隔、指标首次间隔、指标最大间隔、指标监测最长时长均以“正整数 + 单位”填写,支持 `s`(秒)、`m`(分钟)、`h`(小时),数字与单位间允许空格、单位不区分大小写。读取时显示可整除的最大单位,保留原始秒数精度;保存时转换成整数秒,后端接口与数据库仍使用秒。空值、错误格式、非正整数与超出后端时长上限的输入明确报错;间隔倍数和回溯天数不变。
|
||||
|
||||
AI 服务:仅支持 OpenAI Compatible,BASEURL、APIKEY、MODEL 在“系统设置 → AI 服务”保存;BASEURL 是接口根地址,不补默认地址,不包含 `/models` 或 `/chat/completions`。填写地址与密钥后通过 `GET /models` 自动获取模型,MODEL 仅用可搜索下拉选择,支持手动刷新;获取失败、模型为空或已保存模型不可用必须明确提示,不预设模型或手填兜底。实际分析每次使用数据库当前配置,不使用百炼专用客户端或 `BAILIAN_*` 环境变量,不需要额外审批勾选。仅修改 AI 配置或保存未变化的采集配置不得重置采集窗口、游标、进度、完成时间或指标计划,也不因采集运行中而拒绝;实际修改采集配置仍保留运行中冲突检查。原有 MODEL 在数据库更新时保留,首次使用需补齐地址与密钥。
|
||||
|
||||
前端框架:Umi Max 4.7 + React 19;
|
||||
|
||||
@@ -4,21 +4,50 @@ import { Alert, App, Button, Card, Flex, Form, Input, InputNumber, Select, Tabs,
|
||||
import { creatorAction, creatorGet, creatorUpdate } from '@/services/api';
|
||||
import { conflictMessage } from '@/utils/helpers';
|
||||
|
||||
const numericFields = new Set([
|
||||
'lookback_days',
|
||||
const durationFields = new Set([
|
||||
'new_work_interval_seconds',
|
||||
'metric_initial_interval_seconds',
|
||||
'metric_multiplier',
|
||||
'metric_max_interval_seconds',
|
||||
'metric_age_seconds',
|
||||
]);
|
||||
const numericFields = new Set(['lookback_days', 'metric_multiplier']);
|
||||
const editableFields = [
|
||||
...numericFields,
|
||||
...durationFields,
|
||||
'ai_base_url',
|
||||
'ai_api_key',
|
||||
'ai_model',
|
||||
];
|
||||
const editableSettings = (value: any) => Object.fromEntries(editableFields.map((field) => [field, value[field]]));
|
||||
// Match the backend's maximum whole-second Go time.Duration.
|
||||
const maxDurationSeconds = 9223372036;
|
||||
const durationHelp = '正整数 + 单位:s 秒、m 分钟、h 小时,例如 30s、5m、2h;数字与单位间可有空格。';
|
||||
|
||||
function parseDuration(value: unknown): number {
|
||||
const match = typeof value === 'string' && /^(\d+)\s*([smh])$/i.exec(value.trim());
|
||||
if (!match || Number(match[1]) <= 0) {
|
||||
throw new Error('请输入正整数和单位,例如 30s、5m 或 2h。');
|
||||
}
|
||||
const multiplier = { s: 1, m: 60, h: 3600 }[match[2].toLowerCase() as 's' | 'm' | 'h'];
|
||||
const seconds = Number(match[1]) * multiplier;
|
||||
if (!Number.isSafeInteger(seconds) || seconds > maxDurationSeconds) {
|
||||
throw new Error('时长过大,请缩短。');
|
||||
}
|
||||
return seconds;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!Number.isSafeInteger(seconds) || seconds <= 0 || seconds > maxDurationSeconds) {
|
||||
throw new Error('读取的采集时长无效,请检查已保存的设置。');
|
||||
}
|
||||
if (seconds % 3600 === 0) return `${seconds / 3600}h`;
|
||||
if (seconds % 60 === 0) return `${seconds / 60}m`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
const durationRules = [{ validator: async (_: unknown, value: unknown) => { parseDuration(value); } }];
|
||||
const editableSettings = (value: any) => Object.fromEntries(editableFields.map((field) => [
|
||||
field, durationFields.has(field) ? formatDuration(value[field]) : value[field],
|
||||
]));
|
||||
|
||||
type ModelList = {
|
||||
baseURL: string;
|
||||
@@ -109,7 +138,9 @@ export default function Page() {
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = Object.fromEntries(editableFields.map((field) => [field, numericFields.has(field) ? values[field] ?? 0 : String(values[field] ?? '').trim()]));
|
||||
const payload = Object.fromEntries(editableFields.map((field) => [
|
||||
field, durationFields.has(field) ? parseDuration(values[field]) : numericFields.has(field) ? values[field] ?? 0 : String(values[field] ?? '').trim(),
|
||||
]));
|
||||
const result = await creatorUpdate('/creator/settings', payload);
|
||||
form.setFieldsValue(editableSettings(result));
|
||||
messageApi.success('采集与 AI 配置已保存。');
|
||||
@@ -137,8 +168,8 @@ export default function Page() {
|
||||
<Form.Item name="lookback_days" label="竞品回溯天数" extra="首次回溯默认 30 天;不会猜测缺失发布时间。">
|
||||
<InputNumber min={1} style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="new_work_interval_seconds" label="新作品/一级评论间隔(秒)">
|
||||
<InputNumber min={1} style={{ width: 200 }} />
|
||||
<Form.Item name="new_work_interval_seconds" label="新作品/一级评论间隔" extra={durationHelp} rules={durationRules}>
|
||||
<Input placeholder="例如 30m" style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
@@ -147,17 +178,17 @@ export default function Page() {
|
||||
key: 'metrics', label: '指标采集', forceRender: true,
|
||||
children: (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<Form.Item name="metric_initial_interval_seconds" label="指标首次间隔(秒)">
|
||||
<InputNumber min={1} style={{ width: 200 }} />
|
||||
<Form.Item name="metric_initial_interval_seconds" label="指标首次间隔" extra={durationHelp} rules={durationRules}>
|
||||
<Input placeholder="例如 1h" style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="metric_multiplier" label="指标间隔倍数">
|
||||
<InputNumber min={1.01} step={0.01} style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="metric_max_interval_seconds" label="指标最大间隔(秒)">
|
||||
<InputNumber min={1} style={{ width: 200 }} />
|
||||
<Form.Item name="metric_max_interval_seconds" label="指标最大间隔" extra={durationHelp} rules={durationRules}>
|
||||
<Input placeholder="例如 24h" style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="metric_age_seconds" label="指标监测最长时长(秒)">
|
||||
<InputNumber min={1} style={{ width: 200 }} />
|
||||
<Form.Item name="metric_age_seconds" label="指标监测最长时长" extra={durationHelp} rules={durationRules}>
|
||||
<Input placeholder="例如 720h" style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -113,6 +113,80 @@ test('partial connection or a model not from discovery cannot be saved',async t=
|
||||
await h.tick(); tree=h.render(); await find(tree,h.antd.Form).props.onFinish({...h.values,ai_model:'invented-model'}); assert.equal(h.saves.length,0);
|
||||
});
|
||||
|
||||
const durationFields=['new_work_interval_seconds','metric_initial_interval_seconds','metric_max_interval_seconds','metric_age_seconds'];
|
||||
const durationItem=(h,tree,field)=>all(tree,n=>n.type===h.antd.Form.Item).find(n=>n.props.name===field);
|
||||
|
||||
async function validateDuration(h,tree,field,value) {
|
||||
const item=durationItem(h,tree,field);
|
||||
assert.ok(item.props.rules?.length,`${field} must validate duration input`);
|
||||
for(const rule of item.props.rules) await rule.validator({},value);
|
||||
}
|
||||
|
||||
test('all second-based fields display duration units without changing numeric settings',async t=>{
|
||||
const h=harness(); t.after(h.dispose); const tree=await h.flush();
|
||||
assert.deepEqual(durationFields.map(field=>h.values[field]),['30m','1h','24h','720h']);
|
||||
for(const field of durationFields){
|
||||
const item=durationItem(h,tree,field);
|
||||
assert.ok(all(item,n=>n.type===h.antd.Input).length,`${field} must accept a unit suffix`);
|
||||
assert.ok(!text(item.props.label).includes('(秒)'));
|
||||
assert.match(text(item.props.extra),/s.*秒.*m.*分钟.*h.*小时/);
|
||||
}
|
||||
assert.equal(h.values.lookback_days,30); assert.equal(h.values.metric_multiplier,2);
|
||||
});
|
||||
|
||||
test('duration display picks the largest exact unit and keeps every stored second',async t=>{
|
||||
for(const [seconds,display] of [[1,'1s'],[59,'59s'],[60,'1m'],[90,'90s'],[3600,'1h'],[5400,'90m'],[3661,'3661s'],[9223372036,'9223372036s']]){
|
||||
const settings={...defaults}; durationFields.forEach(field=>settings[field]=seconds);
|
||||
const h=harness({settings}); t.after(h.dispose); await h.flush();
|
||||
for(const field of durationFields) assert.equal(h.values[field],display);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid stored durations show an explicit load error instead of a fabricated value',async t=>{
|
||||
for(const seconds of [0,-1,1.5,null,'60',9223372037]){
|
||||
const h=harness({settings:{...defaults,metric_age_seconds:seconds}}); t.after(h.dispose); const tree=await h.flush();
|
||||
assert.match(alerts(h,tree),/采集时长无效/);
|
||||
assert.equal(h.values.metric_age_seconds,undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('duration fields accept seconds minutes hours and optional spacing, saving integer seconds',async t=>{
|
||||
const h=harness(); t.after(h.dispose); await h.flush(); const tree=await h.tick();
|
||||
const inputs=[' 30 s ','5m','2 H',' 48h '];
|
||||
for(const [i,field] of durationFields.entries()) await validateDuration(h,tree,field,inputs[i]);
|
||||
const values={...h.values}; durationFields.forEach((field,i)=>values[field]=inputs[i]);
|
||||
await find(tree,h.antd.Form).props.onFinish(values);
|
||||
assert.equal(h.saves.length,1);
|
||||
assert.deepEqual(durationFields.map(field=>h.saves[0].payload[field]),[30,300,7200,172800]);
|
||||
assert.ok(durationFields.every(field=>Number.isSafeInteger(h.saves[0].payload[field])));
|
||||
assert.deepEqual(durationFields.map(field=>h.values[field]),['30s','5m','2h','48h']);
|
||||
assert.equal(h.saves[0].payload.lookback_days,30); assert.equal(h.saves[0].payload.metric_multiplier,2);
|
||||
});
|
||||
|
||||
test('empty, malformed, nonpositive and overflowing durations are rejected for every time field',async t=>{
|
||||
const h=harness(); t.after(h.dispose); const tree=await h.flush();
|
||||
for(const field of durationFields){
|
||||
for(const input of ['',undefined,null,30,'30','0s','-1h','1.5h','1d','1h30m','1e3s','Infinitys','1 m junk','9223372037s','153722868m','2562048h','9007199254740993s']){
|
||||
await assert.rejects(()=>validateDuration(h,tree,field,input),/正整数|过大/,`${field}: ${input}`);
|
||||
}
|
||||
for(const input of ['1s','9223372036s','153722867m','2562047h']) await validateDuration(h,tree,field,input);
|
||||
}
|
||||
assert.equal(h.saves.length,0);
|
||||
});
|
||||
|
||||
test('invalid duration cannot be sent even if form submission bypasses validation',async t=>{
|
||||
const h=harness(); t.after(h.dispose); await h.flush(); const tree=await h.tick();
|
||||
await find(tree,h.antd.Form).props.onFinish({...h.values,metric_age_seconds:'2d'});
|
||||
assert.equal(h.saves.length,0); assert.match(h.notices.join(' '),/正整数/);
|
||||
});
|
||||
|
||||
test('unchanged duration settings round-trip exactly when saving only AI settings',async t=>{
|
||||
const h=harness(); t.after(h.dispose); await h.flush(); const tree=await h.tick();
|
||||
await find(tree,h.antd.Form).props.onFinish({...h.values,ai_model:'model-b'});
|
||||
for(const field of durationFields) assert.equal(h.saves[0].payload[field],defaults[field]);
|
||||
assert.equal(h.saves[0].payload.ai_model,'model-b');
|
||||
});
|
||||
|
||||
test('load and save failures remain visible',async t=>{
|
||||
const broken=harness({loadError:'settings unavailable'}); t.after(broken.dispose);
|
||||
assert.match(alerts(broken,await broken.flush()),/settings unavailable/);
|
||||
|
||||
Reference in New Issue
Block a user