feat(studio-ds): P3 组件库——AppButton 令牌化(grad-primary/on-accent/focus环) + 新增 Icon(SVG)/Field/AppearanceSwitch

P3 of HJ-FE-DS-001 金样板。通用品牌组件,P4 接入 Feed/Create/Profile。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
zizi 2026-06-16 15:55:35 +00:00
parent ecae9025a9
commit 655673eb99
4 changed files with 281 additions and 4 deletions

View File

@ -77,6 +77,11 @@ function onClick(ev: MouseEvent) {
.app-btn:active {
transform: scale(0.98);
}
/* —— 键盘聚焦环(无障碍;鼠标点击不触发,仅 :focus-visible—— */
.app-btn:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
/* —— 尺寸 —— */
.app-btn--normal {
@ -105,9 +110,9 @@ function onClick(ev: MouseEvent) {
背景 135° cyangreen 渐变 + 重字重 820 + 深色高对比字#061014
primary 用此渐变与字重ghost/danger/plain 不受影响 */
.app-btn--primary {
background: linear-gradient(135deg, var(--cyan), var(--green));
color: #061014;
font-weight: 820;
background: var(--grad-primary);
color: var(--on-accent);
font-weight: var(--fw-cta);
}
/* ghost 透明描边青 */
.app-btn--ghost {
@ -118,7 +123,7 @@ function onClick(ev: MouseEvent) {
/* danger 危险 */
.app-btn--danger {
background: var(--danger);
color: #2a0608;
color: var(--on-danger);
}
/* plain 次要面板 */
.app-btn--plain {

View File

@ -0,0 +1,104 @@
<script setup lang="ts">
/**
* AppearanceSwitch 外观设置主题//+ 语言/分段切换
* 用于我的/设置入口即时持久化 + 全局生效useAppearance 单例 + i18n
*/
import { useI18n } from 'vue-i18n'
import { useAppearance, type ThemeMode, type LangCode } from '../composables/useAppearance'
import Icon from './Icon.vue'
const { t } = useI18n()
const { theme, lang, setTheme, setLang } = useAppearance()
// labelKey i18n
const themes: { val: ThemeMode; labelKey: string }[] = [
{ val: 'dark', labelKey: 'appearance.themeDark' },
{ val: 'light', labelKey: 'appearance.themeLight' },
{ val: 'dim', labelKey: 'appearance.themeDim' },
]
//
const langs: { val: LangCode; labelKey: string }[] = [
{ val: 'zh-CN', labelKey: 'appearance.langZh' },
{ val: 'en-US', labelKey: 'appearance.langEn' },
]
</script>
<template>
<div class="appearance">
<div class="appearance__row">
<span class="appearance__title"><Icon name="settings" :size="16" /> {{ t('appearance.theme') }}</span>
<div class="seg" role="group" :aria-label="t('appearance.theme')">
<button
v-for="it in themes"
:key="it.val"
class="seg__btn"
:class="{ 'seg__btn--on': theme === it.val }"
type="button"
@click="setTheme(it.val)"
>
{{ t(it.labelKey) }}
</button>
</div>
</div>
<div class="appearance__row">
<span class="appearance__title"><Icon name="globe" :size="16" /> {{ t('appearance.language') }}</span>
<div class="seg" role="group" :aria-label="t('appearance.language')">
<button
v-for="it in langs"
:key="it.val"
class="seg__btn"
:class="{ 'seg__btn--on': lang === it.val }"
type="button"
@click="setLang(it.val)"
>
{{ t(it.labelKey) }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.appearance {
display: grid;
gap: var(--sp-3);
}
.appearance__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
flex-wrap: wrap;
}
.appearance__title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-body);
color: var(--ink);
font-weight: var(--fw-label);
}
.seg {
display: inline-flex;
border: 1px solid var(--border-2);
border-radius: var(--radius-md);
padding: 3px;
background: color-mix(in srgb, var(--surface-2) 85%, transparent);
}
.seg__btn {
appearance: none;
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-weight: var(--fw-label);
padding: 6px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: 0.18s ease;
}
.seg__btn--on {
background: var(--grad-primary);
color: var(--on-accent);
}
</style>

View File

@ -0,0 +1,113 @@
<script setup lang="ts">
/**
* Field 通用表单字段label + input/textarea + 错误/帮助文案 + 聚焦环
* 统一替代散落 3 份手搓 .field Detail/BizCreate/BizLeadDetailv-model 双向绑定
*/
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
/** 绑定值v-model */
modelValue?: string
/** 字段标签 */
label?: string
/** 控件类型 */
type?: 'text' | 'textarea' | 'tel' | 'number'
/** 占位符 */
placeholder?: string
/** 错误文案(非空时进入错误态,红框 + 红字) */
error?: string
/** 常驻帮助文案(无错误时显示) */
helper?: string
/** 必填标记 */
required?: boolean
/** textarea 行数 */
rows?: number
}>(),
{ modelValue: '', type: 'text', rows: 4 },
)
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
const isTextarea = computed(() => props.type === 'textarea')
function onInput(e: Event): void {
emit('update:modelValue', (e.target as HTMLInputElement | HTMLTextAreaElement).value)
}
</script>
<template>
<div class="field" :class="{ 'field--error': !!error }">
<label v-if="label" class="field__label">
{{ label }}<span v-if="required" class="field__req" aria-hidden="true">*</span>
</label>
<textarea
v-if="isTextarea"
class="field__ctrl"
:rows="rows"
:value="modelValue"
:placeholder="placeholder"
@input="onInput"
></textarea>
<input
v-else
class="field__ctrl"
:type="type"
:value="modelValue"
:placeholder="placeholder"
@input="onInput"
/>
<span v-if="error" class="field__msg field__msg--error">{{ error }}</span>
<span v-else-if="helper" class="field__msg">{{ helper }}</span>
</div>
</template>
<style scoped>
.field {
display: grid;
gap: 6px;
}
.field__label {
font-size: 13px;
color: var(--muted);
font-weight: var(--fw-medium);
}
.field__req {
color: var(--danger);
margin-left: 3px;
}
.field__ctrl {
width: 100%;
color: var(--ink);
font: inherit;
background: color-mix(in srgb, var(--bg) 72%, transparent);
border: 1px solid var(--border-2);
border-radius: var(--radius-md);
padding: 11px 12px;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.field__ctrl::placeholder {
color: color-mix(in srgb, var(--muted) 75%, transparent);
}
.field__ctrl:focus {
outline: none;
border-color: color-mix(in srgb, var(--accent) 76%, transparent);
box-shadow: var(--focus-ring);
}
textarea.field__ctrl {
min-height: 96px;
line-height: 1.55;
resize: vertical;
}
.field--error .field__ctrl {
border-color: var(--danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 18%, transparent);
}
.field__msg {
font-size: var(--fs-caption);
color: var(--muted);
}
.field__msg--error {
color: var(--danger);
}
</style>

View File

@ -0,0 +1,55 @@
<script setup lang="ts">
/**
* Icon 统一 SVG 图标lucide 取向1.75 描边currentColor 跟随文字色
* 替代散落的 emoji / Unicode 字形满足设计体系 no-emoji-icons 红线
* 用法<Icon name="play" :size="20" />新增图标在 PATHS 注册即可
*/
import { computed } from 'vue'
const props = withDefaults(defineProps<{ name: string; size?: number }>(), { size: 20 })
// viewBox 0 0 24 24stroke=currentColor SVG
const PATHS: Record<string, string> = {
feed: '<rect x="3" y="3" width="18" height="18" rx="3"/><path d="M3 9h18M9 9v12"/>',
create: '<path d="M12 5v14M5 12h14"/>',
message: '<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0"/>',
me: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
heart: '<path d="M20.8 5.6a5 5 0 0 0-7.1 0L12 7.3l-1.7-1.7a5 5 0 0 0-7.1 7.1L12 21l8.8-8.3a5 5 0 0 0 0-7.1z"/>',
star: '<path d="m12 3 2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 18l-5.8 3.4 1.1-6.5L2.6 9.8l6.5-.9z"/>',
share: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/>',
play: '<path d="M7 4v16l13-8z"/>',
back: '<path d="M15 18l-6-6 6-6"/>',
chevron: '<path d="M9 6l6 6-6 6"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
spark: '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18"/>',
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z"/>',
}
// svg
const inner = computed(() => PATHS[props.name] ?? '')
</script>
<template>
<!-- v-html 注入路径片段现代 webview svg innerHTML SVG 命名空间解析 -->
<svg
class="icon"
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
v-html="inner"
></svg>
</template>
<style scoped>
.icon {
display: block;
flex: none;
}
</style>