| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217 |
- import {
- ChangeDetectionStrategy,
- Component,
- ElementRef,
- computed,
- input,
- output,
- viewChild,
- } from '@angular/core';
- /**
- * 底部输入坞(Demo `.dock`)
- *
- * Demo 形态:圆角卡片 + 自适应高度 textarea + 工具行(附件 / 目标 / 发送)。
- * 与桌面 `composer` 的区别:桌面版工具行在上、输入在下;移动端输入在上、
- * 工具行在下(拇指可达区)。事件语义与桌面一致(draftChange / send / attach)。
- *
- * 安全区:坞底由页面负责加 `--mob-safe-bottom`。
- */
- @Component({
- selector: 'app-m-dock',
- changeDetection: ChangeDetectionStrategy.OnPush,
- template: `
- <div class="m-dock">
- <textarea
- rows="1"
- [value]="draft()"
- [placeholder]="placeholder()"
- [attr.aria-label]="placeholder()"
- [disabled]="disabled()"
- (input)="onInput($event)"
- (keydown.enter)="onEnter($event)"
- ></textarea>
- <div class="m-dock-tools">
- <button type="button" class="m-tool" aria-label="添加附件" [disabled]="disabled() || uploading()" (click)="pickFile()">
- <i class="fa-solid fa-paperclip" aria-hidden="true"></i>
- </button>
- <input #fileRef type="file" multiple hidden (change)="onFile($event)" />
- @if (target()) {
- <span class="m-target">
- <i class="fa-solid fa-robot" aria-hidden="true"></i>
- {{ target() }}
- </span>
- }
- <button
- type="button"
- class="m-send"
- [class.ready]="hasContent()"
- [disabled]="disabled() || !hasContent()"
- [attr.aria-label]="sending() ? '发送中' : '发送'"
- (click)="submit()"
- >
- <i [class]="sending() ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-arrow-up'" aria-hidden="true"></i>
- </button>
- </div>
- @if (uploading()) {
- <div class="m-dock-progress" role="progressbar" [attr.aria-valuenow]="pct()" aria-valuemin="0" aria-valuemax="100">
- <i [style.width.%]="pct()"></i>
- </div>
- }
- </div>
- `,
- styles: [
- `
- :host {
- display: block;
- }
- .m-dock {
- margin: 0 var(--sp-4);
- padding: var(--sp-3) var(--sp-4);
- border: 1px solid var(--edge);
- border-radius: var(--r-lg);
- background: var(--surface);
- box-shadow: var(--shadow-pop);
- box-sizing: border-box;
- }
- textarea {
- display: block;
- width: 100%;
- min-height: 44px;
- max-height: 120px;
- padding: var(--sp-2) 0;
- border: 0;
- outline: 0;
- resize: none;
- background: transparent;
- color: var(--ink);
- font: inherit;
- font-size: var(--fs-17);
- line-height: 1.5;
- }
- .m-dock-tools {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- color: var(--muted);
- }
- .m-tool {
- display: grid;
- place-items: center;
- width: 36px;
- height: 36px;
- border: 0;
- border-radius: var(--r-tag);
- background: none;
- color: var(--muted);
- font-size: var(--fs-17);
- cursor: pointer;
- }
- .m-target {
- display: inline-flex;
- align-items: center;
- gap: var(--sp-1);
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: var(--muted);
- font-size: var(--fs-13);
- }
- .m-send {
- margin-left: auto;
- display: grid;
- place-items: center;
- flex: none;
- width: 36px;
- height: 36px;
- border: 0;
- border-radius: var(--r-tag);
- background: var(--primary-subtle);
- color: var(--muted);
- font-size: var(--fs-15);
- cursor: pointer;
- transition:
- background var(--motion-fast) var(--ease-standard),
- color var(--motion-fast) var(--ease-standard);
- }
- .m-send.ready {
- background: var(--primary);
- color: var(--primary-content);
- }
- .m-send:disabled {
- cursor: not-allowed;
- }
- .m-dock-progress {
- height: 3px;
- margin-top: var(--sp-2);
- border-radius: var(--r-tag);
- background: var(--surface-active);
- overflow: hidden;
- }
- .m-dock-progress i {
- display: block;
- height: 100%;
- background: var(--primary);
- transition: width var(--motion-detail) var(--ease-standard);
- }
- `,
- ],
- })
- export class MDockComponent {
- readonly draft = input('');
- readonly placeholder = input('发消息…');
- /** 右侧显示的目标(数字生命名称);空则不显示 */
- readonly target = input('');
- readonly disabled = input(false);
- readonly sending = input(false);
- readonly uploading = input(false);
- readonly pct = input(0);
- readonly draftChange = output<string>();
- readonly send = output<string>();
- readonly attach = output<FileList>();
- /** 是否已有待发附件(由页面传入;纯附件也可发送) */
- readonly hasAttachments = input(false);
- protected readonly fileRef = viewChild.required<ElementRef<HTMLInputElement>>('fileRef');
- /** 文本非空 **或** 有待发附件即可发送 */
- protected readonly hasContent = computed(
- () => (this.draft() ?? '').trim().length > 0 || this.hasAttachments(),
- );
- protected onInput(e: Event): void {
- const el = e.target as HTMLTextAreaElement;
- this.draftChange.emit(el.value);
- el.style.height = 'auto';
- el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
- }
- /** 回车发送,Shift+Enter 换行 */
- protected onEnter(e: Event): void {
- const ke = e as KeyboardEvent;
- if (ke.shiftKey) return;
- ke.preventDefault();
- this.submit();
- }
- protected submit(): void {
- if (this.disabled() || this.sending() || !this.hasContent()) return;
- this.send.emit(this.draft().trim());
- }
- /** 打开系统文件选择器 */
- protected pickFile(): void {
- this.fileRef().nativeElement.click();
- }
- protected onFile(e: Event): void {
- const input = e.target as HTMLInputElement;
- if (input.files && input.files.length > 0) this.attach.emit(input.files);
- input.value = '';
- }
- }
|