diff --git a/frontend/src/components/common/FilePicker.spec.ts b/frontend/src/components/common/FilePicker.spec.ts
new file mode 100644
index 0000000..eab9b3f
--- /dev/null
+++ b/frontend/src/components/common/FilePicker.spec.ts
@@ -0,0 +1,36 @@
+// @vitest-environment happy-dom
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import FilePicker from './FilePicker.vue'
+
+describe('FilePicker', () => {
+ it('keeps the native file input accessible and reports the selected file', async () => {
+ const wrapper = mount(FilePicker, {
+ props: { file: null, label: '选择文件', emptyLabel: '尚未选择文件', accept: '.json' },
+ })
+ const input = wrapper.get('input[type="file"]')
+ const file = new File(['{}'], 'rules.json', { type: 'application/json' })
+ Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
+
+ await input.trigger('change')
+
+ expect(wrapper.emitted('select')).toEqual([[file]])
+ expect(wrapper.get('label').attributes('for')).toBe(input.attributes('id'))
+ expect(wrapper.text()).toContain('尚未选择文件')
+
+ await wrapper.setProps({ file })
+ expect(wrapper.text()).toContain('rules.json')
+ })
+
+ it('emits null when the native selection is cleared', async () => {
+ const wrapper = mount(FilePicker, {
+ props: { file: null, label: '选择文件', emptyLabel: '尚未选择文件' },
+ })
+ const input = wrapper.get('input[type="file"]')
+ Object.defineProperty(input.element, 'files', { value: [], configurable: true })
+
+ await input.trigger('change')
+
+ expect(wrapper.emitted('select')).toEqual([[null]])
+ })
+})
diff --git a/frontend/src/components/common/FilePicker.vue b/frontend/src/components/common/FilePicker.vue
new file mode 100644
index 0000000..d741d11
--- /dev/null
+++ b/frontend/src/components/common/FilePicker.vue
@@ -0,0 +1,58 @@
+
+
+
+ {{ t('上传音频或视频音轨,转写、校对后保存到知识库。单个文件最多 25 MiB。', 'Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Maximum file size: 25 MiB.') }}{{ t('音视频转写', 'Media Transcription') }}
{{ notice }}
{{ selected.original_text }}{{ revision.text }}{{ selected.original_text }}{{ revision.text }}{{ t('提供商通用规则先应用,再应用模型规则。对象递归合并,数组整体替换,null 作为实际值;删除键后恢复继承。密钥继续使用独立 API Key 配置。', 'Provider-wide rules are applied before model rules. Objects merge recursively, arrays replace whole values, and null is kept as a value. Delete a key to inherit it again. API keys remain in the separate credential setting.') }}
{{ transferError }}
{{ t('导入替换当前请求规则,保存提供商后生效。导出仅包含请求规则,不包含凭据引用和 API Key。', 'Importing replaces the current request rules and takes effect after saving the provider. Exports contain rules only, without credential references or API keys.') }}
- + diff --git a/frontend/src/styles/features.css b/frontend/src/styles/features.css index 782c0c2..1a1eaf3 100644 --- a/frontend/src/styles/features.css +++ b/frontend/src/styles/features.css @@ -85,6 +85,44 @@ button:disabled { cursor: not-allowed; opacity: .55; box-shadow: none; transform .input:focus, .select:focus, .textarea:focus { border-color: var(--color-border-focus); box-shadow: 0 0 0 3px var(--color-accent-soft); background: var(--color-surface-primary); } .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-md); } +input[type='checkbox'], input[type='radio'] { + appearance: none; + display: inline-grid; + place-content: center; + width: 18px; + height: 18px; + flex: 0 0 18px; + border: 1px solid var(--color-border-default); + background: var(--color-background-primary); + transition: border-color var(--motion-fast), background-color var(--motion-fast), box-shadow var(--motion-fast); +} +input[type='checkbox'] { border-radius: 5px; } +input[type='radio'] { border-radius: 50%; } +input[type='checkbox']::before, input[type='radio']::before { content: ''; width: 10px; height: 10px; transform: scale(0); transition: transform var(--motion-fast); } +input[type='checkbox']::before { clip-path: polygon(14% 44%, 0 59%, 39% 96%, 100% 20%, 84% 7%, 37% 68%); background: var(--color-text-inverse); } +input[type='radio']::before { border-radius: 50%; background: var(--color-text-inverse); } +input[type='checkbox']:hover, input[type='radio']:hover { border-color: var(--color-accent-primary); } +input[type='checkbox']:checked, input[type='radio']:checked { border-color: var(--color-accent-primary); background: var(--color-accent-primary); } +input[type='checkbox']:checked::before, input[type='radio']:checked::before { transform: scale(1); } +input[type='checkbox']:disabled, input[type='radio']:disabled { cursor: not-allowed; opacity: .55; } + +input[type='range'] { appearance: none; height: 20px; background: transparent; cursor: pointer; } +input[type='range']::-webkit-slider-runnable-track { height: 5px; border-radius: var(--radius-full); background: var(--color-background-tertiary); } +input[type='range']::-webkit-slider-thumb { appearance: none; width: 16px; height: 16px; margin-top: -5.5px; border: 2px solid var(--color-surface-primary); border-radius: 50%; background: var(--color-accent-primary); box-shadow: 0 1px 4px color-mix(in srgb, var(--color-text-primary) 24%, transparent); } + +progress { appearance: none; width: 100%; height: 8px; overflow: hidden; border: 0; border-radius: var(--radius-full); background: var(--color-background-tertiary); } +progress::-webkit-progress-bar { border-radius: var(--radius-full); background: var(--color-background-tertiary); } +progress::-webkit-progress-value { border-radius: var(--radius-full); background: linear-gradient(90deg, var(--color-accent-primary), var(--color-accent-secondary)); } +progress:not([value]) { background: linear-gradient(90deg, var(--color-background-tertiary) 25%, var(--color-accent-secondary) 50%, var(--color-background-tertiary) 75%); background-size: 200% 100%; animation: progress-pulse 1.2s linear infinite; } + +.ui-disclosure { padding: var(--space-md); border: 1px solid var(--color-border-default); border-radius: var(--radius-md); background: var(--color-background-secondary); } +.ui-disclosure > summary { display: flex; align-items: center; justify-content: space-between; gap: var(--space-sm); list-style: none; color: var(--color-text-secondary); font-weight: 600; cursor: pointer; } +.ui-disclosure > summary::-webkit-details-marker { display: none; } +.ui-disclosure > summary::after { content: ''; width: 8px; height: 8px; flex: 0 0 auto; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); transition: transform var(--motion-fast); } +.ui-disclosure[open] > summary::after { transform: rotate(225deg); } +.ui-disclosure[open] > summary { margin-bottom: var(--space-md); color: var(--color-text-primary); } +.ui-disclosure > :not(summary) + :not(summary) { margin-top: var(--space-sm); } + .badge { display: inline-flex; align-items: center; @@ -143,6 +181,8 @@ button:disabled { cursor: not-allowed; opacity: .55; box-shadow: none; transform to { opacity: 1; transform: translateY(0) scale(1); } } +@keyframes progress-pulse { from { background-position: 100% 0; } to { background-position: -100% 0; } } + @media (max-width: 900px) { .feature-page { padding: var(--space-lg); } .split-view { grid-template-columns: 1fr; }