task-247(admin.html观感对齐): 行内工具组件化(状态药丸/复制文本/掩码揭示复用 secret-mask)
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { copyFeedback, isCopySupported } from './copy-model'
|
||||||
|
|
||||||
|
const props = defineProps<{ text: string }>()
|
||||||
|
|
||||||
|
async function handleCopy(): Promise<void> {
|
||||||
|
if (!props.text || !isCopySupported()) {
|
||||||
|
ElMessage.warning(copyFeedback(''))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(props.text)
|
||||||
|
ElMessage.success(copyFeedback(props.text))
|
||||||
|
} catch {
|
||||||
|
ElMessage.error(copyFeedback(''))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="copy-text" role="button" tabindex="0" :title="text" @click="handleCopy"><slot>{{ text }}</slot></span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.copy-text { cursor: pointer; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { secretPlaceholder } from '@/pages/shop/secret-mask'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MaskReveal 掩码揭示(module 13 task 247):默认以店铺 secret-mask 掩码占位展示,
|
||||||
|
* 点击切换时向父组件发 reveal 事件取明文(以 value prop 回填);短时后自动收回。
|
||||||
|
*/
|
||||||
|
const props = withDefaults(defineProps<{ value?: string; revealMs?: number }>(), {
|
||||||
|
value: '',
|
||||||
|
revealMs: 5000,
|
||||||
|
})
|
||||||
|
const emit = defineEmits<{ (e: 'reveal'): void }>()
|
||||||
|
|
||||||
|
const revealed = ref(false)
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function clearTimer(): void {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(): void {
|
||||||
|
revealed.value = true
|
||||||
|
emit('reveal')
|
||||||
|
clearTimer()
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
revealed.value = false
|
||||||
|
}, props.revealMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide(): void {
|
||||||
|
clearTimer()
|
||||||
|
revealed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(): void {
|
||||||
|
if (revealed.value) hide()
|
||||||
|
else show()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.value,
|
||||||
|
(v) => {
|
||||||
|
if (!v) revealed.value = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(clearTimer)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="mask-reveal">
|
||||||
|
<span class="mask-reveal-text">{{ revealed && value ? value : secretPlaceholder() }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mask-reveal-toggle"
|
||||||
|
:aria-label="revealed ? '隐藏' : '显示'"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
{{ revealed ? '隐藏' : '显示' }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mask-reveal { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.mask-reveal-text { font-variant-numeric: tabular-nums; }
|
||||||
|
.mask-reveal-toggle {
|
||||||
|
border: 1px solid #cbd9e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #5b6f83;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mask-reveal-toggle:hover { color: #2f5d8b; border-color: #95b1cb; background: #edf5fb; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { pillToneStyle, type PillTone } from './status-pill-model'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ text: string; tone?: PillTone }>(), { tone: 'info' })
|
||||||
|
const style = computed(() => pillToneStyle(props.tone))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="status-pill" :style="{ color: style.text, background: style.bg }">{{ text }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 62px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 复制文本反馈模型(module 13 task 247):非空给出「已复制 X」,失败/空值给降级文案。
|
||||||
|
*/
|
||||||
|
export const COPY_FEEDBACK_FALLBACK = '复制失败,请手动选择'
|
||||||
|
|
||||||
|
export function copyFeedback(text: string): string {
|
||||||
|
const t = typeof text === 'string' ? text.trim() : ''
|
||||||
|
return t ? `已复制 ${t}` : COPY_FEEDBACK_FALLBACK
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCopySupported(): boolean {
|
||||||
|
return typeof navigator !== 'undefined' && typeof navigator.clipboard?.writeText === 'function'
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 状态药丸配色/分类模型(module 13 task 247):统一 admin.html 的三态语义色
|
||||||
|
* (success 绿 / danger 红 / warning 琥珀 / info 蓝 / primary 深蓝),供视频任务、
|
||||||
|
* 店铺数据任务、数字人版本等页面复用;未知文本回落 info。
|
||||||
|
*/
|
||||||
|
export type PillTone = 'success' | 'danger' | 'warning' | 'info' | 'primary'
|
||||||
|
|
||||||
|
export interface PillToneStyle {
|
||||||
|
text: string
|
||||||
|
bg: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PILL_TONES: Record<PillTone, PillToneStyle> = {
|
||||||
|
success: { text: '#4e806d', bg: '#e6f6ec' },
|
||||||
|
danger: { text: '#b35f6a', bg: '#fdecec' },
|
||||||
|
warning: { text: '#a8793e', bg: '#fdf0dc' },
|
||||||
|
info: { text: '#4f78a5', bg: '#e7f0f8' },
|
||||||
|
primary: { text: '#2f5d8b', bg: '#e6f0f8' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取某 tone 的配色;未知 tone 回退 info(不抛错)。 */
|
||||||
|
export function pillToneStyle(tone: PillTone): PillToneStyle {
|
||||||
|
return PILL_TONES[tone] ?? PILL_TONES.info
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUCCESS_PATTERN = /success|succeed|已发布|发布|成功|正常|可用/
|
||||||
|
const DANGER_PATTERN = /fail|error|已废弃|失败|不可用|异常/
|
||||||
|
const WARNING_PATTERN = /running|pending|waiting|进行|处理中|草稿|待执行|待/
|
||||||
|
|
||||||
|
/** 由状态文本(中英文)归类语义 tone。 */
|
||||||
|
export function statusTone(raw: string): PillTone {
|
||||||
|
const s = (raw || '').toLowerCase()
|
||||||
|
if (SUCCESS_PATTERN.test(s)) return 'success'
|
||||||
|
if (DANGER_PATTERN.test(s)) return 'danger'
|
||||||
|
if (WARNING_PATTERN.test(s)) return 'warning'
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
import { pillToneStyle, PILL_TONES, statusTone, type PillTone } from '../src/components/status-pill-model.ts'
|
||||||
|
import { copyFeedback } from '../src/components/copy-model.ts'
|
||||||
|
|
||||||
|
// module 13 task 247:行内工具组件化 —— 状态药丸/复制文本/掩码揭示,供视频/店铺数据/
|
||||||
|
// 数字人版本等页面复用;掩码复用店铺中心 secret-mask 语义。
|
||||||
|
|
||||||
|
test('test_task_247_pill_model_normal_primary_path', () => {
|
||||||
|
// 正常主路径:状态文本 → 语义 tone。
|
||||||
|
assert.equal(statusTone('SUCCESS'), 'success')
|
||||||
|
assert.equal(statusTone('FAILED'), 'danger')
|
||||||
|
assert.equal(statusTone('已发布'), 'success')
|
||||||
|
assert.equal(statusTone('失败'), 'danger')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_247_pill_model_normal_variant_input', () => {
|
||||||
|
// 正常变体:运行/草稿等中间态落 warning;未知回落 info。
|
||||||
|
assert.equal(statusTone('RUNNING'), 'warning')
|
||||||
|
assert.equal(statusTone('草稿'), 'warning')
|
||||||
|
assert.equal(statusTone('something-unknown'), 'info')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_247_pill_model_normal_repeated_operation_is_idempotent', () => {
|
||||||
|
assert.equal(statusTone('SUCCESS'), statusTone('success'))
|
||||||
|
assert.deepEqual(pillToneStyle('success'), pillToneStyle('success'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_247_pill_model_boundary_empty_input', () => {
|
||||||
|
// 边界空:空值/未知名不崩溃。
|
||||||
|
assert.equal(statusTone(''), 'info')
|
||||||
|
assert.equal(statusTone(undefined as unknown as string), 'info')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_247_pill_model_boundary_single_item', () => {
|
||||||
|
// 边界单元素:每个 tone 都有可读配色(药丸文字/底色为 admin 三态色)。
|
||||||
|
for (const tone of Object.keys(PILL_TONES) as PillTone[]) {
|
||||||
|
const s = pillToneStyle(tone)
|
||||||
|
assert.match(s.text, /^#[0-9a-f]{6}$/)
|
||||||
|
assert.match(s.bg, /^#[0-9a-f]{6}$/)
|
||||||
|
}
|
||||||
|
assert.equal(pillToneStyle('success').text, '#4e806d')
|
||||||
|
assert.equal(pillToneStyle('danger').text, '#b35f6a')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_247_pill_model_invalid_input_rejected', () => {
|
||||||
|
// 异常输入:非法 tone 回退 info,不抛错;三态主色与 admin 语义一致。
|
||||||
|
assert.equal(pillToneStyle('bogus' as PillTone).text, PILL_TONES.info.text)
|
||||||
|
assert.equal(PILL_TONES.warning.text, '#a8793e')
|
||||||
|
assert.equal(PILL_TONES.info.text, '#4f78a5')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_247_copy_model_boundary_limit_or_missing_field', () => {
|
||||||
|
// 复制反馈:非空给「已复制 X」,空/失败给降级文案。
|
||||||
|
assert.equal(copyFeedback('B001'), '已复制 B001')
|
||||||
|
assert.equal(copyFeedback(' '), '复制失败,请手动选择')
|
||||||
|
assert.equal(copyFeedback(''), '复制失败,请手动选择')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_247_components_dependency_failure_returns_actionable_message', () => {
|
||||||
|
// 依赖失败:组件真实存在且接入语义(药丸用 PILL_TONES、复制走 copyFeedback、掩码复用 secret-mask)。
|
||||||
|
assert.ok(readSource('src/components/StatusPill.vue').length > 0)
|
||||||
|
assert.ok(readSource('src/components/CopyText.vue').length > 0)
|
||||||
|
assert.ok(readSource('src/components/MaskReveal.vue').length > 0)
|
||||||
|
const copy = readSource('src/components/CopyText.vue')
|
||||||
|
assert.match(copy, /copyFeedback/)
|
||||||
|
assert.match(copy, /navigator\.clipboard/)
|
||||||
|
const mask = readSource('src/components/MaskReveal.vue')
|
||||||
|
assert.match(mask, /secret-mask|secretPlaceholder|SECRET_PLACEHOLDER/, '掩码应复用店铺 secret-mask 语义')
|
||||||
|
const pill = readSource('src/components/StatusPill.vue')
|
||||||
|
assert.match(pill, /pillToneStyle|PILL_TONES/, '药丸应使用统一配色模型')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user